diff --git a/build.rs b/build.rs
index e0a2b905..de115e8c 100644
--- a/build.rs
+++ b/build.rs
@@ -27,18 +27,6 @@ const HTML_INCLUDE: &[&str] = &[
"share.html",
];
-// ─── View CSS files linked directly in index.html (not via @import) ──────────
-const INDEX_VIEW_CSS: &[&str] = &[
- "views/inlineViewer.css",
- "views/favorites.css",
- "views/recent.css",
- "views/shared.css",
- "views/trash.css",
- "views/photos.css",
- "views/photosLightbox.css",
- "views/music.css",
-];
-
// ═══════════════════════════════════════════════════════════════════════════════
// Entry point
// ═══════════════════════════════════════════════════════════════════════════════
@@ -91,18 +79,28 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
let css_dir = static_dir.join("css");
+ // Read index.html once — used for both CSS and JS extraction.
+ let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
+
// ── 2. Resolve main.css @imports ─────────────────────────────────────────
let resolved_main = resolve_css_imports(&css_dir.join("main.css"), &css_dir);
let minified_main = css_minify_safe(&resolved_main);
fs::write(dist_dir.join("css/main.css"), &minified_main).expect("write main.css");
// ── 3. Build CSS bundle for index.html ───────────────────────────────────
+ // Derive the list of view CSS files directly from the tags in index.html
+ // so build.rs never needs to be updated when a new stylesheet is added.
let mut css_all = resolved_main;
- for view in INDEX_VIEW_CSS {
- let p = css_dir.join(view);
+ for view in extract_css_links(&index_html) {
+ let p = css_dir.join(&view);
if p.exists() {
css_all.push_str(&fs::read_to_string(&p).unwrap_or_default());
css_all.push('\n');
+ } else {
+ eprintln!(
+ "cargo:warning=CSS link in index.html not found: {}",
+ p.display()
+ );
}
}
let css_bundle = css_minify_safe(&css_all);
@@ -116,7 +114,6 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
// ── 5. Bundle all ES modules into one IIFE ───────────────────────────────
// Walk the import graph starting from every
-
diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js
index 086e4964..e8dfd10f 100644
--- a/static/js/app/filesView.js
+++ b/static/js/app/filesView.js
@@ -15,6 +15,7 @@
*/
import { ResourceListComponent } from '../components/resourceList.js';
+import { shareModal } from '../components/shareModal.js';
import { normalizeDateBucket, sizeBucket } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import * as viewPrefs from '../core/viewPrefs.js';
@@ -218,6 +219,12 @@ function _ensureComponent() {
_component?.setFavoriteVisualState(item.id, type, true);
}
},
+ onShareBadgeClick: (item) => {
+ const isFile = 'mime_type' in item;
+ shareModal.open(item, isFile ? 'file' : 'folder', () => {
+ grants.fetchOutgoingGrants().then(() => refreshSharedBadges());
+ });
+ },
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
onSelectionChange: (selectedItems) => {
batchToolbar._selected.clear();
@@ -454,4 +461,12 @@ async function loadFiles(options = { insertHistory: true }) {
}
}
-export { addItem, filesView, loadFiles };
+/**
+ * Re-evaluate the shared badge for every item currently rendered in the Files list.
+ * Call this after the outgoing grants cache has been refreshed.
+ */
+function refreshSharedBadges() {
+ _component?.refreshSharedBadges();
+}
+
+export { addItem, filesView, loadFiles, refreshSharedBadges };
diff --git a/static/js/app/main.js b/static/js/app/main.js
index cfc21802..26f67204 100644
--- a/static/js/app/main.js
+++ b/static/js/app/main.js
@@ -18,7 +18,6 @@ import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { grants } from '../model/grants.js';
import { recentView } from '../views/recent/recentView.js';
-import { sharedView } from '../views/shared/sharedView.js';
import { checkAuthentication } from './authSession.js';
import { loadFiles } from './filesView.js';
import {
@@ -162,12 +161,30 @@ const ACTIONS_BAR_TEMPLATES = {
${_batchToolbarButons}
${_toggleButtons}
+ `,
+ shared: `
+
+
`
};
/**
*
- * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'hidden'} mode
+ * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'shared' | 'hidden'} mode
* @param {boolean} [force=false]
* @returns
*/
@@ -259,8 +276,12 @@ function syncGroupByMenu(defs = []) {
// Rebuild menu options — call i18n.t() directly so each label is resolved
// at call time (translations are loaded by the time any section switch runs).
- menu.innerHTML = ``;
+ // A def with key='' lets the section override the default "None" label.
+ const noneOverride = defs.find((d) => d.key === '');
+ const noneLabel = noneOverride ? noneOverride.label : i18n.t('groupby.none', 'None');
+ menu.innerHTML = ``;
for (const def of defs) {
+ if (def.key === '') continue;
menu.insertAdjacentHTML('beforeend', ``);
}
@@ -508,6 +529,12 @@ function initApp() {
window.addEventListener('authenticationDone', async () => {
// Check if a context was provided in the URL
const hashContext = deserializeHash();
+
+ // Always fetch grants so shared badges are correct regardless of the
+ // initial section. Fire in the background — don't block section init.
+ grants.fetchIncomingGrants();
+ grants.fetchOutgoingGrants();
+
switchSectionTo(hashContext.section);
if (hashContext.section === 'files') {
if (hashContext.path) {
@@ -519,9 +546,6 @@ function initApp() {
app.viewFile = hashContext.file;
}
- // get grants (xxx: async methods)
- await grants.fetchIncomingGrants();
- await grants.fetchOutgoingGrants();
loadFiles();
}
});
@@ -640,8 +664,10 @@ function setupEventListeners() {
} else {
// change is from history, data provided in event
switchSectionTo(e.state.section);
- app.currentPath = e.state.id;
- loadFiles({ insertHistory: false });
+ if (e.state.section === 'files') {
+ app.currentPath = e.state.id;
+ loadFiles({ insertHistory: false });
+ }
}
});
@@ -676,11 +702,8 @@ function setupEventListeners() {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput?.value.trim();
- // In shared section, filter locally
- if (app.currentSection === 'shared' && sharedView) {
- sharedView.filterAndSortItems();
- return;
- }
+ // My Shares section does not support in-page search
+ if (app.currentSection === 'shared') return;
if (query) {
performSearch(query);
diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js
index 0274f44a..adec3924 100644
--- a/static/js/app/navigation.js
+++ b/static/js/app/navigation.js
@@ -10,11 +10,12 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
import { favorites } from '../features/library/favorites.js';
import { musicView } from '../features/library/music.js';
import { photosView } from '../features/library/photos.js';
+import { grants } from '../model/grants.js';
import { favoritesView } from '../views/favorites/favoritesView.js';
+import { mySharesView } from '../views/myShares/mySharesView.js';
import { recentView } from '../views/recent/recentView.js';
-import { sharedView } from '../views/shared/sharedView.js';
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
-import { filesView, loadFiles } from './filesView.js';
+import { filesView, loadFiles, refreshSharedBadges } from './filesView.js';
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
import { app, appElements } from './state.js';
import { loadTrashItems } from './trashView.js';
@@ -165,9 +166,9 @@ function setCurrentSection(section) {
appElements.pageTitle.setAttribute('data-i18n', titleKey);
}
- // Hide sharedView when switching to any other section
- if (section !== 'shared' && sharedView) {
- sharedView.hide();
+ // Hide mySharesView when switching to any other section
+ if (section !== 'shared') {
+ mySharesView.hide();
}
// Hide "Load more" button when leaving the sharedwithme section
@@ -208,21 +209,27 @@ function switchToSharedSection() {
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.add('hidden');
- // Hide actions-bar for shared view
- setActionsBarMode('hidden');
+ // Show actions-bar with group-by controls only (no grid/list toggle —
+ // MySharesList is always in list mode).
+ setActionsBarMode('shared');
- //reset files view + remove any error
- ui.resetFilesList();
+ // Populate the group-by dropdown with this section's dimensions.
+ setGroupByView(mySharesView);
+ syncGroupByMenu(mySharesView.groupByDefs);
- // Hide file containers
- toggleFileContainer(false);
+ // Restore the saved group-by selection in the dropdown.
+ const msPrefs = viewPrefs.load('shared');
+ applyGroupByMenuState(msPrefs.groupBy, msPrefs.reversed);
- // Show shared view
- sharedView.init().then(() => {
- sharedView.show();
- });
+ // Show the files container always in list view — grid is not applicable here.
+ toggleFileContainer(true);
+ app.currentView = 'list';
+ syncViewContainers();
if (batchToolbar) batchToolbar.clear();
+
+ // Load and render items into the files container
+ mySharesView.init();
}
function switchToSharedWithMeSection() {
@@ -293,10 +300,13 @@ function switchToFilesSection() {
ui.updateBreadcrumb();
if (batchToolbar) batchToolbar.clear();
- // temp solution
- sharedView.loadItems().then(() => {
- loadFiles();
- });
+ loadFiles();
+
+ // Refresh outgoing grants in the background and repaint badges once done.
+ // Badges are rendered synchronously from the in-memory cache, so any staleness
+ // from navigating away and back (or starting on a different section) is corrected
+ // without blocking the file list render.
+ grants.fetchOutgoingGrants().then(() => refreshSharedBadges());
}
function switchToFavoritesSection() {
diff --git a/static/js/app/ui.js b/static/js/app/ui.js
index 991280a3..d16854e5 100644
--- a/static/js/app/ui.js
+++ b/static/js/app/ui.js
@@ -882,7 +882,7 @@ const ui = {
loadFiles();
return;
}
- if (app.currentSection === 'sharedwithme') {
+ if (app.currentSection === 'sharedwithme' || app.currentSection === 'shared') {
// Activate Files UI (nav, breadcrumb, actions bar) without
// resetting the path — the shared folder becomes the entry point.
activateFilesUI();
diff --git a/static/js/components/linkChip.js b/static/js/components/linkChip.js
new file mode 100644
index 00000000..1ee863fb
--- /dev/null
+++ b/static/js/components/linkChip.js
@@ -0,0 +1,53 @@
+/**
+ * linkChip — inline clickable element representing a share link.
+ *
+ * Renders: [🔒/🔗] Link - ...{last4 of UUID} - {name}
+ * Clicking copies the share URL to the clipboard.
+ */
+
+import { i18n } from '../core/i18n.js';
+import { fileSharing } from '../features/sharing/fileSharing.js';
+
+/** @import {OutgoingResourceGrant} from '../core/types.js' */
+
+/**
+ * Build an inline link chip. Clicking it copies the share URL.
+ * @param {OutgoingResourceGrant} grant
+ * @returns {HTMLButtonElement}
+ */
+function buildLinkChip(grant) {
+ const btn = /** @type {HTMLButtonElement} */ (document.createElement('button'));
+ btn.className = `link-chip${grant.has_password ? ' link-chip--locked' : ''}`;
+ btn.type = 'button';
+ btn.title = i18n.t('share.copyLink', 'Copy link');
+
+ const icon = document.createElement('i');
+ icon.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon';
+ btn.appendChild(icon);
+
+ const last4 = grant.subject_id.slice(-4);
+ const label = `${i18n.t('share.link', 'Link')} - ...${last4} - ${grant.subject_display}`;
+
+ const text = document.createElement('span');
+ text.className = 'link-chip__label';
+ text.textContent = label;
+ btn.appendChild(text);
+
+ btn.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ btn.disabled = true;
+ try {
+ const share = await fileSharing.getShareById(grant.subject_id);
+ await fileSharing.copyLinkToClipboard(share.url);
+ } catch (err) {
+ console.error('linkChip: copy failed', err);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+
+ return btn;
+}
+
+export { buildLinkChip };
diff --git a/static/js/components/modal.js b/static/js/components/modal.js
index 79166e72..8704ca31 100644
--- a/static/js/components/modal.js
+++ b/static/js/components/modal.js
@@ -346,14 +346,15 @@ const Modal = {
*
* @param {Object} options
* @param {string} options.title
- * @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
- * @param {HTMLElement} options.content - DOM node to inject into .modal-body
- * @param {string} [options.confirmText] - Confirm button label
- * @param {string} [options.cancelText] - Cancel button label
- * @param {() => void} [options.onConfirm] - Called when Confirm is clicked
- * @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
+ * @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
+ * @param {HTMLElement} options.content - DOM node to inject into .modal-body
+ * @param {string} [options.confirmText] - Confirm button label
+ * @param {string} [options.cancelText] - Cancel button label
+ * @param {boolean} [options.confirmDisabled] - Initial disabled state of the confirm button
+ * @param {() => void} [options.onConfirm] - Called when Confirm is clicked
+ * @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
*/
- openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) {
+ openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, confirmDisabled = false, onConfirm = null, onCancel = null }) {
if (!this.overlay) return;
this._panelMode = true;
@@ -379,7 +380,7 @@ const Modal = {
// ── Footer buttons ──────────────────────────────────────────────────
if (this.confirmBtn) {
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
- this.confirmBtn.disabled = false;
+ this.confirmBtn.disabled = confirmDisabled;
}
if (this.cancelBtn) {
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js
new file mode 100644
index 00000000..49a4ae2e
--- /dev/null
+++ b/static/js/components/mySharesList.js
@@ -0,0 +1,600 @@
+/**
+ * MySharesList — row-per-grant list for the My Shares view.
+ *
+ * Both view modes emit one row per grant. Lane headers are emitted on
+ * grouping-key change — the server guarantees ORDER BY group key first.
+ *
+ * Modes:
+ * 'items' — lane = resource; row identity = subject
+ * 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource
+ */
+
+import { i18n } from '../core/i18n.js';
+import { fileSharing } from '../features/sharing/fileSharing.js';
+import { grants } from '../model/grants.js';
+import { buildExpiryChip } from '../utils/expiryChip.js';
+import { buildPasswordChip } from '../utils/passwordChip.js';
+import { buildLinkChip } from './linkChip.js';
+import { buildResourceIcon } from './resourceIcon.js';
+import { createUserVignette } from './userVignette.js';
+
+/**
+ * @import {OutgoingResourceItem, OutgoingResourceGrant, FileItem, FolderItem} from '../core/types.js'
+ * @typedef {'items'|'sharedWith'} ViewMode
+ * @typedef {'never'|'active'|'soon'|'expired'} ExpiryState
+ */
+
+const SOON_DAYS = 30;
+
+/**
+ * @param {string|null|undefined} expiresAt
+ * @returns {ExpiryState}
+ */
+function _expiryState(expiresAt) {
+ if (!expiresAt) return 'never';
+ const ms = new Date(expiresAt).getTime() - Date.now();
+ if (ms < 0) return 'expired';
+ if (ms <= SOON_DAYS * 86_400_000) return 'soon';
+ return 'active';
+}
+
+/** @param {string} role @returns {string} */
+function _roleLabel(role) {
+ /** @type {Record} */
+ const m = {
+ admin: i18n.t('share.role.canManage', 'Can manage'),
+ editor: i18n.t('share.role.canEdit', 'Can edit'),
+ viewer: i18n.t('share.role.canView', 'Can view')
+ };
+ return m[role] ?? role;
+}
+
+/** @param {string} role @returns {'manage'|'edit'|'view'} */
+function _roleMod(role) {
+ if (role === 'admin') return 'manage';
+ if (role === 'editor') return 'edit';
+ return 'view';
+}
+
+class MySharesList {
+ /**
+ * @param {HTMLElement} container
+ * @param {{
+ * onResourceOpen: (resource: FileItem|FolderItem, resourceType: string) => void,
+ * onShareEdit: (resource: FileItem|FolderItem, resourceType: string) => void,
+ * }} config
+ */
+ constructor(container, config) {
+ this._container = container;
+ this._config = config;
+ /** @type {string|null} */
+ this._lastSwimKey = null;
+ /** @type {HTMLElement|null} */
+ this._lastSwimEl = null;
+ }
+
+ clear() {
+ this._container.innerHTML = '';
+ this._lastSwimKey = null;
+ this._lastSwimEl = null;
+ }
+
+ /**
+ * Full re-render (page 1).
+ * @param {OutgoingResourceItem[]} items
+ * @param {ViewMode} viewMode
+ */
+ render(items, viewMode) {
+ this.clear();
+ this._ingest(items, viewMode);
+ }
+
+ /**
+ * Cursor append (page 2+).
+ * @param {OutgoingResourceItem[]} items
+ * @param {ViewMode} viewMode
+ */
+ append(items, viewMode) {
+ this._ingest(items, viewMode);
+ }
+
+ // ── Core ingest ───────────────────────────────────────────────────────────
+
+ /**
+ * @param {OutgoingResourceItem[]} items
+ * @param {ViewMode} viewMode
+ */
+ _ingest(items, viewMode) {
+ for (const item of items) {
+ if (viewMode === 'items') {
+ this._ingestItemsMode(item);
+ } else {
+ this._ingestSharedWithMode(item);
+ }
+ }
+ }
+
+ /**
+ * Items mode — one lane per resource, one grant row per grant.
+ * @param {OutgoingResourceItem} item
+ */
+ _ingestItemsMode(item) {
+ const swimKey = `resource:${item.resource.id}`;
+ const laneBody = this._ensureLane(swimKey, () => this._buildResourceLaneHeader(item));
+ for (const grant of item.grants) {
+ laneBody.appendChild(this._buildGrantRow(grant, item, 'items'));
+ }
+ }
+
+ /**
+ * SharedWith mode — one lane per user or per link bucket, one row per grant.
+ * @param {OutgoingResourceItem} item
+ */
+ _ingestSharedWithMode(item) {
+ for (const grant of item.grants) {
+ let swimKey;
+ if (grant.subject_type === 'user') {
+ swimKey = `user:${grant.subject_id}`;
+ } else if (grant.has_password) {
+ swimKey = 'links:password';
+ } else {
+ swimKey = 'links:public';
+ }
+ const laneBody = this._ensureLane(swimKey, () => this._buildSubjectLaneHeader(swimKey, grant));
+ laneBody.appendChild(this._buildGrantRow(grant, item, 'sharedWith'));
+ }
+ }
+
+ // ── Lane management ───────────────────────────────────────────────────────
+
+ /**
+ * Return the existing lane body when swimKey matches, else create a new lane.
+ * @param {string} swimKey
+ * @param {() => HTMLElement} buildHeader
+ * @returns {HTMLElement}
+ */
+ _ensureLane(swimKey, buildHeader) {
+ if (swimKey === this._lastSwimKey && this._lastSwimEl) return this._lastSwimEl;
+
+ const lane = document.createElement('div');
+ lane.className = 'ms-lane';
+ lane.dataset.swimKey = swimKey;
+
+ const header = document.createElement('div');
+ header.className = 'ms-lane__header';
+ header.appendChild(buildHeader());
+ lane.appendChild(header);
+
+ const body = document.createElement('div');
+ body.className = 'ms-lane__body';
+ lane.appendChild(body);
+
+ this._container.appendChild(lane);
+ this._lastSwimKey = swimKey;
+ this._lastSwimEl = body;
+ return body;
+ }
+
+ /**
+ * Lane header for items mode: resource icon + name link + Edit sharing button.
+ * @param {OutgoingResourceItem} item
+ * @returns {HTMLElement}
+ */
+ _buildResourceLaneHeader(item) {
+ const row = document.createElement('div');
+ row.className = 'ms-resource-row';
+
+ row.appendChild(buildResourceIcon(item.resource, item.resource_type));
+
+ const nameLink = document.createElement('a');
+ nameLink.className = 'ms-resource-row__name';
+ nameLink.href = '#';
+ nameLink.textContent = item.resource.name;
+ nameLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ this._config.onResourceOpen(item.resource, item.resource_type);
+ });
+ row.appendChild(nameLink);
+
+ const editBtn = document.createElement('button');
+ editBtn.className = 'ms-resource-row__edit button ghost';
+ editBtn.innerHTML = ` ${i18n.t('myshares.editSharing', 'Edit sharing')}`;
+ editBtn.addEventListener('click', () => this._config.onShareEdit(item.resource, item.resource_type));
+ row.appendChild(editBtn);
+
+ return row;
+ }
+
+ /**
+ * Lane header for sharedWith mode: user vignette or link bucket label.
+ * @param {string} swimKey
+ * @param {OutgoingResourceGrant} grant
+ * @returns {HTMLElement}
+ */
+ _buildSubjectLaneHeader(swimKey, grant) {
+ if (swimKey.startsWith('user:')) {
+ return createUserVignette(grant.subject_id, 'list');
+ }
+ const el = document.createElement('div');
+ el.className = 'ms-link-lane-label';
+ const icon = document.createElement('i');
+ if (swimKey === 'links:password') {
+ icon.className = 'fas fa-lock ms-link-lane-label__icon';
+ el.appendChild(icon);
+ el.appendChild(document.createTextNode(` ${i18n.t('myshares.passwordLinks', 'Password-protected links')}`));
+ } else {
+ icon.className = 'fas fa-link ms-link-lane-label__icon';
+ el.appendChild(icon);
+ el.appendChild(document.createTextNode(` ${i18n.t('myshares.publicLinks', 'Public links')}`));
+ }
+ return el;
+ }
+
+ // ── Grant row ─────────────────────────────────────────────────────────────
+
+ /**
+ * One grant row: identity + role pill + expiry chip + ⋯ button.
+ * @param {OutgoingResourceGrant} grant
+ * @param {OutgoingResourceItem} item
+ * @param {ViewMode} viewMode
+ * @returns {HTMLElement}
+ */
+ _buildGrantRow(grant, item, viewMode) {
+ const row = document.createElement('div');
+ row.className = 'ms-grant-row';
+ if (_expiryState(grant.expires_at ?? null) === 'expired') {
+ row.classList.add('ms-grant-row--expired');
+ }
+
+ row.appendChild(this._buildIdentity(grant, item, viewMode));
+ row.appendChild(this._buildRolePill(grant.role));
+ row.appendChild(this._buildExpiryChip(grant.expires_at ?? null));
+ row.appendChild(this._buildKebabBtn(grant, item, row));
+
+ return row;
+ }
+
+ /**
+ * Identity: user vignette or link icon + name; tokens in sharedWith mode add → resource.
+ * @param {OutgoingResourceGrant} grant
+ * @param {OutgoingResourceItem} item
+ * @param {ViewMode} viewMode
+ * @returns {HTMLElement}
+ */
+ _buildIdentity(grant, item, viewMode) {
+ const el = document.createElement('div');
+ el.className = 'ms-grant-row__identity';
+
+ if (grant.subject_type === 'user' && viewMode === 'sharedWith') {
+ // Lane header is already the user — show the resource instead
+ el.appendChild(buildResourceIcon(item.resource, item.resource_type));
+ const nameLink = document.createElement('a');
+ nameLink.className = 'ms-identity__resource-name';
+ nameLink.href = '#';
+ nameLink.textContent = item.resource.name;
+ nameLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ this._config.onResourceOpen(item.resource, item.resource_type);
+ });
+ el.appendChild(nameLink);
+ } else if (grant.subject_type === 'user') {
+ el.appendChild(createUserVignette(grant.subject_id, 'xs'));
+ } else {
+ // Token — link chip handles icon + label + copy-on-click
+ el.appendChild(buildLinkChip(grant));
+
+ if (viewMode === 'sharedWith') {
+ const arrow = document.createElement('span');
+ arrow.className = 'ms-link-identity__arrow';
+ arrow.textContent = '→';
+ el.appendChild(arrow);
+
+ const resLink = document.createElement('a');
+ resLink.className = 'ms-link-identity__resource';
+ resLink.href = '#';
+ resLink.appendChild(buildResourceIcon(item.resource, item.resource_type));
+ resLink.appendChild(document.createTextNode(` ${item.resource.name}`));
+ resLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ this._config.onResourceOpen(item.resource, item.resource_type);
+ });
+ el.appendChild(resLink);
+ }
+ }
+
+ return el;
+ }
+
+ /** @param {string} role @returns {HTMLElement} */
+ _buildRolePill(role) {
+ const pill = document.createElement('span');
+ pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`;
+ pill.textContent = _roleLabel(role);
+ return pill;
+ }
+
+ /**
+ * 4-state expiry chip: never / active / soon / expired.
+ * @param {string|null} expiresAt
+ * @returns {HTMLElement}
+ */
+ _buildExpiryChip(expiresAt) {
+ const state = _expiryState(expiresAt);
+ const chip = document.createElement('span');
+ chip.className = `ms-expiry-chip ms-expiry-chip--${state}`;
+
+ const icon = document.createElement('i');
+ const text = document.createTextNode('');
+
+ if (state === 'never') {
+ icon.className = 'fas fa-infinity';
+ chip.appendChild(icon);
+ chip.appendChild(document.createTextNode(` ${i18n.t('myshares.neverExpires', 'Never expires')}`));
+ } else if (state === 'expired') {
+ icon.className = 'fas fa-exclamation-triangle';
+ chip.appendChild(icon);
+ chip.appendChild(document.createTextNode(` ${i18n.t('myshares.expired', 'Expired')}`));
+ } else if (state === 'soon' && expiresAt) {
+ icon.className = 'fas fa-clock';
+ const days = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
+ const label =
+ days <= 1
+ ? i18n.t('myshares.expiresTomorrow', 'Expires tomorrow')
+ : i18n.t('myshares.expiresInDays', 'Expires in {n} days').replace('{n}', String(days));
+ chip.appendChild(icon);
+ chip.appendChild(document.createTextNode(` ${label}`));
+ } else if (expiresAt) {
+ icon.className = 'fas fa-clock';
+ const d = new Date(expiresAt);
+ const fmt = d.toLocaleDateString('default', { day: 'numeric', month: 'short', year: 'numeric' });
+ chip.appendChild(icon);
+ chip.appendChild(document.createTextNode(` ${i18n.t('myshares.until', 'Until')} ${fmt}`));
+ }
+
+ // unused ref kept to avoid TS unused-var warning suppression
+ void text;
+ return chip;
+ }
+
+ // ── Kebab menu ────────────────────────────────────────────────────────────
+
+ /**
+ * @param {OutgoingResourceGrant} grant
+ * @param {OutgoingResourceItem} item
+ * @param {HTMLElement} rowEl
+ * @returns {HTMLButtonElement}
+ */
+ _buildKebabBtn(grant, item, rowEl) {
+ const btn = /** @type {HTMLButtonElement} */ (document.createElement('button'));
+ btn.className = 'ms-kebab-btn ms-btn-icon';
+ btn.setAttribute('aria-label', i18n.t('myshares.manageAccess', 'Manage access'));
+ btn.innerHTML = '';
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._openGrantMenu(btn, grant, item, rowEl);
+ });
+ return btn;
+ }
+
+ /**
+ * Build and show a dynamic context menu positioned below the trigger button.
+ * @param {HTMLButtonElement} btn
+ * @param {OutgoingResourceGrant} grant
+ * @param {OutgoingResourceItem} item
+ * @param {HTMLElement} rowEl
+ */
+ _openGrantMenu(btn, grant, item, rowEl) {
+ document.querySelector('.ms-grant-menu')?.remove();
+
+ const menu = document.createElement('div');
+ menu.className = 'context-menu ms-grant-menu';
+
+ // Current expiry as YYYY-MM-DD (or null)
+ const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null;
+
+ if (grant.subject_type === 'user') {
+ for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
+ const isCurrent = grant.role === role;
+ const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', _roleLabel(role), false, async () => {
+ menu.remove();
+ if (isCurrent) return;
+ await grants.updateRole({
+ subject: { type: grant.subject_type, id: grant.subject_id },
+ resource: { type: item.resource_type, id: item.resource.id },
+ role
+ });
+ const pill = rowEl.querySelector('.ms-role-pill');
+ if (pill) {
+ pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`;
+ pill.textContent = _roleLabel(role);
+ }
+ grant.role = role;
+ });
+ if (isCurrent) mi.classList.add('ms-menu-item--current');
+ menu.appendChild(mi);
+ }
+ menu.appendChild(this._menuSeparator());
+ menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry));
+ menu.appendChild(this._menuSeparator());
+ menu.appendChild(
+ this._menuItem('fas fa-user-times', i18n.t('myshares.removeAccess', 'Remove access'), true, async () => {
+ menu.remove();
+ await grants.revokeGrant(grant.grant_id);
+ this._removeRowAndCleanLane(rowEl);
+ })
+ );
+ } else {
+ menu.appendChild(
+ this._menuItem('fas fa-copy', i18n.t('myshares.copyLink', 'Copy link'), false, async () => {
+ menu.remove();
+ const share = await fileSharing.getShareById(grant.subject_id);
+ await fileSharing.copyLinkToClipboard(share.url);
+ })
+ );
+ menu.appendChild(this._menuSeparator());
+ menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry));
+ menu.appendChild(this._menuPasswordRow(grant, rowEl));
+ menu.appendChild(this._menuSeparator());
+ menu.appendChild(
+ this._menuItem('fas fa-trash', i18n.t('myshares.deleteLink', 'Delete link'), true, async () => {
+ menu.remove();
+ await fileSharing.removeSharedLink(grant.subject_id);
+ this._removeRowAndCleanLane(rowEl);
+ })
+ );
+ }
+
+ document.body.appendChild(menu);
+
+ // Position below the trigger, right-aligned to it, clamped to viewport
+ const rect = btn.getBoundingClientRect();
+ const mw = menu.offsetWidth || 200;
+ const left = Math.min(rect.right - mw, window.innerWidth - mw - 8);
+ menu.style.position = 'absolute';
+ menu.style.top = `${rect.bottom + window.scrollY + 4}px`;
+ menu.style.left = `${Math.max(8, left)}px`;
+
+ const close = (/** @type {Event} */ e) => {
+ if (e.type === 'keydown' && /** @type {KeyboardEvent} */ (e).key !== 'Escape') return;
+ // Keep menu open when interacting with elements inside it (e.g. the date input)
+ if (e.type === 'click' && menu.contains(/** @type {Node} */ (e.target))) return;
+ menu.remove();
+ document.removeEventListener('click', close, true);
+ document.removeEventListener('keydown', close, true);
+ };
+ setTimeout(() => {
+ document.addEventListener('click', close, true);
+ document.addEventListener('keydown', close, true);
+ }, 0);
+ }
+
+ /**
+ * Non-closing expiry row embedded in the context menu.
+ * Uses the shared smd-expiry-chip; saves on blur/Enter.
+ * @param {OutgoingResourceGrant} grant
+ * @param {OutgoingResourceItem} item
+ * @param {HTMLElement} rowEl
+ * @param {string|null} initialExpiry YYYY-MM-DD or null
+ * @returns {HTMLElement}
+ */
+ _menuExpiryRow(grant, item, rowEl, initialExpiry) {
+ const row = document.createElement('div');
+ row.className = 'ms-menu-expiry-row';
+
+ const label = document.createElement('span');
+ label.className = 'ms-menu-expiry-label';
+ label.textContent = i18n.t('share.expiry', 'Expiry');
+ row.appendChild(label);
+
+ const chip = buildExpiryChip(initialExpiry, async (dateStr) => {
+ const expiresIso = dateStr ? new Date(`${dateStr}T00:00:00Z`).toISOString() : null;
+ try {
+ await grants.updateRole({
+ subject: { type: grant.subject_type, id: grant.subject_id },
+ resource: { type: item.resource_type, id: item.resource.id },
+ role: grant.role,
+ expires_at: expiresIso
+ });
+ grant.expires_at = expiresIso;
+ // Replace the display chip in the grant row
+ const displayChip = rowEl.querySelector('.ms-expiry-chip');
+ if (displayChip) {
+ const newChip = this._buildExpiryChip(expiresIso);
+ displayChip.replaceWith(newChip);
+ }
+ } catch (err) {
+ console.error('mySharesList: setExpiry failed', err);
+ }
+ });
+ row.appendChild(chip);
+
+ return row;
+ }
+
+ /**
+ * Non-closing password row embedded in the link context menu.
+ * Saves immediately on confirm (blur / Enter).
+ * @param {OutgoingResourceGrant} grant
+ * @param {HTMLElement} rowEl
+ * @returns {HTMLElement}
+ */
+ _menuPasswordRow(grant, rowEl) {
+ const row = document.createElement('div');
+ row.className = 'ms-menu-expiry-row';
+
+ const label = document.createElement('span');
+ label.className = 'ms-menu-expiry-label';
+ label.textContent = i18n.t('share.password', 'Password');
+ row.appendChild(label);
+
+ const chip = buildPasswordChip(grant.has_password, async (newPassword) => {
+ try {
+ await fileSharing.updateSharedLink(grant.subject_id, {
+ password: newPassword || null
+ });
+ grant.has_password = !!newPassword;
+ // Update the lock icon on the link chip in the row
+ const linkChipEl = rowEl.querySelector('.link-chip');
+ if (linkChipEl) {
+ linkChipEl.classList.toggle('link-chip--locked', grant.has_password);
+ const iconEl = linkChipEl.querySelector('.link-chip__icon');
+ if (iconEl) {
+ iconEl.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon';
+ }
+ }
+ } catch (err) {
+ console.error('mySharesList: setPassword failed', err);
+ }
+ });
+ row.appendChild(chip);
+
+ return row;
+ }
+
+ /**
+ * @param {string} iconClass
+ * @param {string} label
+ * @param {boolean} danger
+ * @param {() => void} onClick
+ * @returns {HTMLElement}
+ */
+ _menuItem(iconClass, label, danger, onClick) {
+ const el = document.createElement('div');
+ el.className = danger ? 'context-menu-item context-menu-item-danger' : 'context-menu-item';
+ el.setAttribute('role', 'menuitem');
+ if (iconClass) {
+ el.innerHTML = ` `;
+ }
+ el.appendChild(document.createTextNode(label));
+ el.addEventListener('click', /** @type {EventListener} */ (onClick));
+ return el;
+ }
+
+ /** @returns {HTMLElement} */
+ _menuSeparator() {
+ const el = document.createElement('div');
+ el.className = 'context-menu-separator';
+ return el;
+ }
+
+ /**
+ * Remove the row; if the lane body is now empty, remove the whole lane.
+ * @param {HTMLElement} rowEl
+ */
+ _removeRowAndCleanLane(rowEl) {
+ const laneBody = rowEl.closest('.ms-lane__body');
+ rowEl.remove();
+ if (laneBody instanceof HTMLElement && laneBody.children.length === 0) {
+ const lane = laneBody.closest('.ms-lane');
+ if (lane instanceof HTMLElement) {
+ if (lane.dataset.swimKey === this._lastSwimKey) {
+ this._lastSwimKey = null;
+ this._lastSwimEl = null;
+ }
+ lane.remove();
+ }
+ }
+ }
+}
+
+export { MySharesList };
diff --git a/static/js/components/resourceIcon.js b/static/js/components/resourceIcon.js
new file mode 100644
index 00000000..ec0a797d
--- /dev/null
+++ b/static/js/components/resourceIcon.js
@@ -0,0 +1,61 @@
+/**
+ * resourceIcon — shared resource icon builder.
+ *
+ * Returns a `.file-icon` element identical to the one in resourceList:
+ * • Folders: `.file-icon.folder-icon` with CSS tab (no visible )
+ * • Files: `.file-icon.{specialClass}` + optional thumbnail
+
+ *
+ * CSS lives in fileType.css (folder/file type colours) and resourceList.css
+ * (base size in grid/list context). Consumer views add their own size overrides.
+ */
+
+import { thumbnail } from '../features/thumbnail.js';
+
+/** @import {FileItem, FolderItem} from '../core/types.js' */
+
+/**
+ * @param {FileItem|FolderItem} item
+ * @param {'file'|'folder'} resourceType
+ * @returns {HTMLElement}
+ */
+function buildResourceIcon(item, resourceType) {
+ const el = document.createElement('div');
+
+ if (resourceType === 'folder') {
+ el.className = 'file-icon folder-icon';
+ const i = document.createElement('i');
+ i.className = 'fas fa-folder';
+ el.appendChild(i);
+ return el;
+ }
+
+ const file = /** @type {FileItem} */ (item);
+ const iconClass = file.icon_class || 'fas fa-file';
+ const iconSpecialClass = file.icon_special_class || '';
+ el.className = `file-icon${iconSpecialClass ? ` ${iconSpecialClass}` : ''}`;
+
+ const canThumbnail = thumbnail?.canHandle(file) ?? false;
+ if (canThumbnail) {
+ const img = document.createElement('img');
+ img.className = 'file-thumb';
+ img.src = `/api/files/${file.id}/thumbnail/icon`;
+ img.loading = 'lazy';
+ img.alt = '';
+ img.addEventListener('error', () => {
+ img.classList.add('hidden');
+ thumbnail?.queueGenerate(file, (dataUrl) => {
+ img.src = dataUrl;
+ img.classList.remove('hidden');
+ });
+ });
+ el.appendChild(img);
+ }
+
+ const i = document.createElement('i');
+ i.className = iconClass;
+ el.appendChild(i);
+
+ return el;
+}
+
+export { buildResourceIcon };
diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js
index a160ac3c..564c7807 100644
--- a/static/js/components/resourceList.js
+++ b/static/js/components/resourceList.js
@@ -20,8 +20,8 @@
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
-import { thumbnail } from '../features/thumbnail.js';
import { systemUsers } from '../model/systemUsers.js';
+import { buildResourceIcon } from './resourceIcon.js';
import { createUserVignette } from './userVignette.js';
/**
@@ -55,7 +55,9 @@ import { createUserVignette } from './userVignette.js';
* @property {(item: FileItem|FolderItem) => Promise} [onFavoriteToggle]
* Called when the user clicks the favorite-star button.
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu]
- * Called for the three-dots button click, right-click, and shared-badge click.
+ * Called for the three-dots button click and right-click.
+ * @property {(item: FileItem|FolderItem) => void} [onShareBadgeClick]
+ * Called when the user clicks the shared badge. Falls back to onContextMenu if absent.
* @property {(selected: Array) => void} [onSelectionChange]
* Called whenever the selection set changes.
*/
@@ -333,6 +335,19 @@ export class ResourceListComponent {
item.querySelector('.file-badge-shared')?.classList.toggle('hidden', !isShared);
}
+ /**
+ * Re-evaluate the shared badge for every currently rendered item using the
+ * `isShared` callback from config. Call this after the grants cache is refreshed.
+ */
+ refreshSharedBadges() {
+ if (!this._cfg.isShared) return;
+ for (const item of this._items.values()) {
+ const isFile = 'mime_type' in item;
+ const type = /** @type {'file'|'folder'} */ (isFile ? 'file' : 'folder');
+ this.setSharedVisualState(item.id, type, this._cfg.isShared(item.id, type));
+ }
+ }
+
// ── Private helpers ─────────────────────────────────────────────────────
/**
@@ -444,9 +459,7 @@ export class ResourceListComponent {
el.innerHTML = `
${cfg.selectable ? '' : ''}
-
-
-
+
${escapeHtml(folder.name)}
${cfg.showFavorite ? `
` : ''}
${cfg.showShareBadge ? `
` : ''}
@@ -461,6 +474,7 @@ export class ResourceListComponent {
`;
+ el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
this._bindItemEvents(el, folder);
return el;
}
@@ -472,8 +486,6 @@ export class ResourceListComponent {
*/
_createFileItem(file) {
const cfg = this._cfg;
- const iconClass = file.icon_class || 'fas fa-file';
- const iconSpecialClass = file.icon_special_class || '';
const cat = file.category || '';
const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document');
const fileSize = file.size_formatted || formatFileSize(file.size);
@@ -481,7 +493,6 @@ export class ResourceListComponent {
const formattedDate = formatDateTime(new Date(dateVal));
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
- const canThumbnail = thumbnail?.canHandle(file) ?? false;
const el = document.createElement('div');
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
@@ -496,10 +507,7 @@ export class ResourceListComponent {
el.innerHTML = `
${cfg.selectable ? '' : ''}
-
- ${canThumbnail ? `

` : ''}
-
-
+
${escapeHtml(file.name)}
${cfg.showFavorite ? `
` : ''}
${cfg.showShareBadge ? `
` : ''}
@@ -514,18 +522,7 @@ export class ResourceListComponent {
`;
- const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
- if (thumb) {
- thumb.addEventListener('error', () => {
- console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`);
- thumb.classList.add('hidden');
- thumbnail?.queueGenerate(file, (dataUrl) => {
- thumb.src = dataUrl;
- thumb.classList.remove('hidden');
- });
- });
- }
-
+ el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
this._bindItemEvents(el, file);
return el;
}
@@ -550,14 +547,18 @@ export class ResourceListComponent {
});
}
- // Shared-badge click → treat as context-menu trigger (e.g. open share modal)
- if (cfg.showShareBadge && cfg.onContextMenu) {
+ // Shared-badge click → open share modal (or fall back to context menu)
+ if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) {
const badge = el.querySelector('.file-badge-shared');
badge?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
- cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
+ if (cfg.onShareBadgeClick) {
+ cfg.onShareBadgeClick(item);
+ } else {
+ cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
+ }
});
}
}
diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js
index 929186c3..fac04542 100644
--- a/static/js/components/shareModal.js
+++ b/static/js/components/shareModal.js
@@ -20,13 +20,13 @@ import { fileSharing } from '../features/sharing/fileSharing.js';
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
+import { buildExpiryChip } from '../utils/expiryChip.js';
+import { buildPasswordChip } from '../utils/passwordChip.js';
import { Modal } from './modal.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
-// ── Helpers ────────────────────────────────────────────────────────────────────
-
/** Permissions that belong to each role (must mirror the Rust DTO). */
const ROLE_PERMISSIONS = {
viewer: ['read'],
@@ -101,24 +101,33 @@ const shareModal = {
/** @type {ShareRoleEnum} */
_stagedRole: 'viewer',
+ /** @type {string|null} — YYYY-MM-DD expiry for the next staged users batch */
+ _stagedExpiry: null,
+
/** @type {HTMLElement|null} — body node injected into Modal */
_bodyEl: null,
+ /** @type {(() => void)|null} — called after changes are successfully committed */
+ _onApplied: null,
+
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Open the share modal for a file or folder.
* @param {FileItem|FolderItem} item
* @param {'file'|'folder'} itemType
+ * @param {(() => void)=} onApplied - called after changes are successfully committed
*/
- async open(item, itemType) {
+ async open(item, itemType, onApplied) {
this._item = item;
this._itemType = itemType;
+ this._onApplied = onApplied ?? null;
this._localMembers = [];
this._localLinks = [];
this._newLinks = [];
this._stagedUsers = [];
this._stagedRole = 'viewer';
+ this._stagedExpiry = null;
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
@@ -130,6 +139,7 @@ const shareModal = {
icon: 'fa-share-alt',
content: this._bodyEl,
confirmText: i18n.t('actions.apply', 'Apply'),
+ confirmDisabled: true,
onConfirm: () => {
this._applyAll();
} // intentionally discard Promise
@@ -164,6 +174,17 @@ const shareModal = {
Modal.close(false);
},
+ // ── Apply-button state ─────────────────────────────────────────────────────
+
+ /** @returns {boolean} */
+ _hasPendingChanges() {
+ return this._localMembers.some((m) => m._op !== 'keep') || this._localLinks.some((e) => e._op !== 'keep') || this._newLinks.length > 0;
+ },
+
+ _syncApplyBtn() {
+ if (Modal.confirmBtn) Modal.confirmBtn.disabled = !this._hasPendingChanges();
+ },
+
// ── Skeleton ───────────────────────────────────────────────────────────────
/**
@@ -248,9 +269,9 @@ const shareModal = {
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-role-select';
for (const [val, label] of [
- ['viewer', i18n.t('share.role.viewer', 'Viewer')],
- ['editor', i18n.t('share.role.editor', 'Editor')],
- ['admin', i18n.t('share.role.admin', 'Admin')]
+ ['viewer', i18n.t('share.role.canView', 'Can view')],
+ ['editor', i18n.t('share.role.canEdit', 'Can edit')],
+ ['admin', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
@@ -262,6 +283,11 @@ const shareModal = {
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
});
+ // ── Expiry chip ──────────────────────────────────────────────────────
+ const expiryChip = this._buildExpiryChip(null, (v) => {
+ this._stagedExpiry = v;
+ });
+
// ── Add button ───────────────────────────────────────────────────────
const addBtn = document.createElement('button');
addBtn.className = 'smd-add-btn btn btn-secondary';
@@ -316,6 +342,7 @@ const shareModal = {
row.appendChild(wrap);
row.appendChild(roleSelect);
+ row.appendChild(expiryChip);
row.appendChild(addBtn);
return row;
@@ -419,7 +446,7 @@ const shareModal = {
/** @type {Grant} */
const placeholderGrant = {
id: '', // not yet persisted
- granted_at: 0,
+ granted_at: '',
granted_by: '',
subject: { type: 'user', id: contact.id },
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
@@ -429,7 +456,8 @@ const shareModal = {
grant: placeholderGrant,
_grants: [], // no server grants yet — nothing to revoke on remove
role: this._stagedRole,
- _op: 'new'
+ _op: 'new',
+ expires_at: this._stagedExpiry
});
}
this._stagedUsers = [];
@@ -450,6 +478,7 @@ const shareModal = {
_refreshMemberGroups() {
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
if (container) this._renderMemberGroupsInto(container);
+ this._syncApplyBtn();
},
/**
@@ -471,9 +500,9 @@ const shareModal = {
header.className = 'smd-group-header';
const labelMap = {
- admin: i18n.t('share.role.admin', 'Admin'),
- editor: i18n.t('share.role.editor', 'Editor'),
- viewer: i18n.t('share.role.viewer', 'Viewer')
+ admin: i18n.t('share.role.canManage', 'Can manage'),
+ editor: i18n.t('share.role.canEdit', 'Can edit'),
+ viewer: i18n.t('share.role.canView', 'Can view')
};
const badge = document.createElement('span');
badge.className = 'smd-group-badge';
@@ -504,9 +533,9 @@ const shareModal = {
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';
for (const [val, label] of [
- ['viewer', i18n.t('share.role.viewer', 'Viewer')],
- ['editor', i18n.t('share.role.editor', 'Editor')],
- ['admin', i18n.t('share.role.admin', 'Admin')]
+ ['viewer', i18n.t('share.role.canView', 'Can view')],
+ ['editor', i18n.t('share.role.canEdit', 'Can edit')],
+ ['admin', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
@@ -521,6 +550,19 @@ const shareModal = {
this._refreshMemberGroups();
});
+ // ── Expiry chip ──────────────────────────────────────────────────────
+ // Initialise entry.expires_at once from the representative grant so that
+ // role-only changes preserve the current expiry across row rebuilds.
+ if (!Object.hasOwn(entry, 'expires_at')) {
+ const raw = entry.grant.expires_at ?? null;
+ entry.expires_at = raw ? String(raw).slice(0, 10) : null;
+ }
+ const expiryChip = this._buildExpiryChip(entry.expires_at, (v) => {
+ entry.expires_at = v;
+ if (entry._op !== 'new') entry._op = 'change';
+ this._syncApplyBtn();
+ });
+
const removeBtn = document.createElement('button');
removeBtn.className = 'smd-row-action';
removeBtn.title = i18n.t('actions.remove', 'Remove');
@@ -532,10 +574,31 @@ const shareModal = {
row.appendChild(vignette);
row.appendChild(roleSelect);
+ row.appendChild(expiryChip);
row.appendChild(removeBtn);
return row;
},
+ // ── Expiry chip toggle ─────────────────────────────────────────────────────
+
+ /**
+ * @param {string|null} initialValue - YYYY-MM-DD or null
+ * @param {(v: string|null) => void} onChange
+ * @returns {HTMLElement}
+ */
+ _buildExpiryChip(initialValue, onChange) {
+ return buildExpiryChip(initialValue, onChange);
+ },
+
+ /**
+ * @param {boolean} initialHasPassword
+ * @param {(v: string) => void} onChange '' = remove / clear, non-empty = set new password
+ * @returns {HTMLElement}
+ */
+ _buildPasswordChip(initialHasPassword, onChange) {
+ return buildPasswordChip(initialHasPassword, onChange);
+ },
+
// ── Links section ──────────────────────────────────────────────────────────
/**
@@ -550,29 +613,72 @@ const shareModal = {
title.textContent = i18n.t('share.publicLinks', 'Public links');
section.appendChild(title);
+ section.appendChild(this._buildAddLinkRow());
+
const listEl = document.createElement('div');
listEl.id = 'smd-links-list';
this._renderLinksInto(listEl);
section.appendChild(listEl);
- const newLinkBtn = document.createElement('button');
- newLinkBtn.className = 'smd-new-link-btn';
- newLinkBtn.innerHTML = ` ${i18n.t('share.createLink', 'Create new public link')}`;
- newLinkBtn.id = 'smd-new-link-btn';
+ return section;
+ },
- const newLinkForm = document.createElement('div');
- newLinkForm.id = 'smd-new-link-form';
- newLinkForm.className = 'smd-new-link-form hidden';
- newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm));
+ /**
+ * Always-visible add-link row — mirrors the People search row layout.
+ * Rebuilds itself after each Add to reset chip state.
+ * @returns {HTMLElement}
+ */
+ _buildAddLinkRow() {
+ const row = document.createElement('div');
+ row.className = 'smd-search-row';
+ row.id = 'smd-add-link-row';
- newLinkBtn.addEventListener('click', () => {
- newLinkBtn.classList.add('hidden');
- newLinkForm.classList.remove('hidden');
+ // Name input — wrapped in smd-search-wrap so it inherits flex:1
+ const wrap = document.createElement('div');
+ wrap.className = 'smd-search-wrap';
+ const nameInput = document.createElement('input');
+ nameInput.type = 'text';
+ nameInput.className = 'smd-search-input';
+ nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Link name (optional)');
+ wrap.appendChild(nameInput);
+
+ /** @type {string|null} */
+ let stagedPassword = null;
+ /** @type {string|null} */
+ let stagedExpiry = null;
+
+ const pwChip = this._buildPasswordChip(false, (v) => {
+ stagedPassword = v || null;
});
- section.appendChild(newLinkBtn);
- section.appendChild(newLinkForm);
- return section;
+ const expChip = this._buildExpiryChip(null, (v) => {
+ stagedExpiry = v;
+ });
+
+ const addBtn = document.createElement('button');
+ addBtn.className = 'smd-add-btn btn btn-secondary';
+ addBtn.textContent = i18n.t('actions.add', 'Add');
+
+ addBtn.addEventListener('click', () => {
+ /** @type {DraftLink} */
+ const draft = {
+ name: nameInput.value.trim(),
+ password: stagedPassword,
+ expires_at: stagedExpiry
+ };
+ this._newLinks.push(draft);
+ this._refreshLinks();
+ // Reset row (also resets chips via closure state)
+ const fresh = this._buildAddLinkRow();
+ row.replaceWith(fresh);
+ });
+
+ row.appendChild(wrap);
+ row.appendChild(pwChip);
+ row.appendChild(expChip);
+ row.appendChild(addBtn);
+
+ return row;
},
/**
@@ -595,6 +701,7 @@ const shareModal = {
_refreshLinks() {
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
if (container) this._renderLinksInto(container);
+ this._syncApplyBtn();
},
/**
@@ -603,87 +710,65 @@ const shareModal = {
*/
_buildLinkRow(entry) {
const share = entry.share;
- const draft = entry._op === 'edit' ? entry._draft : null;
- // Display values: prefer draft overrides when in edit-pending state
- const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link');
- const displayPw = draft ? draft.password !== null : share.has_password;
- const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null;
+ const ensureDraft = () => {
+ if (!entry._draft) {
+ entry._draft = {
+ name: share.item_name || '',
+ password: null,
+ expires_at: share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null
+ };
+ entry._op = 'edit';
+ this._syncApplyBtn();
+ }
+ return entry._draft;
+ };
+
+ // Derive current display values from draft if present, otherwise from share
+ const currentHasPassword = entry._draft
+ ? entry._draft.password === ''
+ ? false
+ : entry._draft.password
+ ? true
+ : share.has_password
+ : share.has_password;
+ const currentExpiry = entry._draft ? entry._draft.expires_at : share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null;
const row = document.createElement('div');
row.className = 'smd-link-row';
- const icon = document.createElement('div');
- icon.className = 'smd-link-icon';
- icon.innerHTML = '';
-
- const info = document.createElement('div');
- info.className = 'smd-link-info';
-
const name = document.createElement('div');
name.className = 'smd-link-name';
- name.textContent = displayName;
+ name.textContent = entry._draft?.name || share.item_name || i18n.t('share.sharedLink', 'Shared link');
- const tags = document.createElement('div');
- tags.className = 'smd-link-tags';
- if (displayPw) {
- const t = document.createElement('span');
- t.className = 'smd-link-tag';
- t.innerHTML = ` ${i18n.t('share.passwordProtected', 'Password')}`;
- tags.appendChild(t);
- }
- if (displayExp) {
- const t = document.createElement('span');
- t.className = 'smd-link-tag';
- t.innerHTML = ` ${displayExp}`;
- tags.appendChild(t);
- }
-
- info.appendChild(name);
- if (tags.children.length) info.appendChild(tags);
-
- const actions = document.createElement('div');
- actions.className = 'smd-link-actions';
-
- // Copy
const copyBtn = document.createElement('button');
copyBtn.className = 'smd-row-action';
- copyBtn.title = i18n.t('actions.copy', 'Copy');
+ copyBtn.title = i18n.t('actions.copy', 'Copy link');
copyBtn.innerHTML = '';
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
- // Edit
- const editBtn = document.createElement('button');
- editBtn.className = 'smd-row-action';
- editBtn.title = i18n.t('actions.edit', 'Edit');
- editBtn.innerHTML = '';
- editBtn.addEventListener('click', () => {
- const panel = row.nextElementSibling;
- if (panel?.classList.contains('smd-edit-panel')) {
- panel.classList.toggle('hidden');
- } else {
- const editPanel = this._buildEditPanel(entry, row);
- row.after(editPanel);
- }
+ const pwChip = this._buildPasswordChip(currentHasPassword, (v) => {
+ ensureDraft().password = v;
+ });
+
+ const expChip = this._buildExpiryChip(currentExpiry, (v) => {
+ ensureDraft().expires_at = v;
});
- // Delete
const delBtn = document.createElement('button');
delBtn.className = 'smd-row-action';
delBtn.title = i18n.t('actions.delete', 'Delete');
- delBtn.innerHTML = '';
+ delBtn.innerHTML = '';
delBtn.addEventListener('click', () => {
entry._op = 'remove';
this._refreshLinks();
});
- actions.appendChild(copyBtn);
- actions.appendChild(editBtn);
- actions.appendChild(delBtn);
-
- row.appendChild(icon);
- row.appendChild(info);
- row.appendChild(actions);
+ row.appendChild(name);
+ row.appendChild(copyBtn);
+ row.appendChild(pwChip);
+ row.appendChild(expChip);
+ row.appendChild(delBtn);
return row;
},
@@ -695,42 +780,21 @@ const shareModal = {
const row = document.createElement('div');
row.className = 'smd-link-row';
- const icon = document.createElement('div');
- icon.className = 'smd-link-icon';
- icon.innerHTML = '';
-
- const info = document.createElement('div');
- info.className = 'smd-link-info';
-
const name = document.createElement('div');
name.className = 'smd-link-name';
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
- const tags = document.createElement('div');
- tags.className = 'smd-link-tags';
- if (draft.password) {
- const t = document.createElement('span');
- t.className = 'smd-link-tag';
- t.innerHTML = ` ${i18n.t('share.passwordProtected', 'Password')}`;
- tags.appendChild(t);
- }
- if (draft.expires_at) {
- const t = document.createElement('span');
- t.className = 'smd-link-tag';
- t.innerHTML = ` ${draft.expires_at}`;
- tags.appendChild(t);
- }
-
const pending = document.createElement('span');
pending.className = 'smd-link-tag';
pending.textContent = i18n.t('share.pending', 'Pending');
- tags.appendChild(pending);
- info.appendChild(name);
- if (tags.children.length) info.appendChild(tags);
+ const pwChip = this._buildPasswordChip(!!draft.password, (v) => {
+ draft.password = v || null;
+ });
- const actions = document.createElement('div');
- actions.className = 'smd-link-actions';
+ const expChip = this._buildExpiryChip(draft.expires_at, (v) => {
+ draft.expires_at = v;
+ });
const delBtn = document.createElement('button');
delBtn.className = 'smd-row-action';
@@ -741,158 +805,14 @@ const shareModal = {
this._refreshLinks();
});
- actions.appendChild(delBtn);
- row.appendChild(icon);
- row.appendChild(info);
- row.appendChild(actions);
+ row.appendChild(name);
+ row.appendChild(pending);
+ row.appendChild(pwChip);
+ row.appendChild(expChip);
+ row.appendChild(delBtn);
return row;
},
- /**
- * @param {LinkEntry} entry
- * @param {HTMLElement} row
- * @returns {HTMLElement}
- */
- _buildEditPanel(entry, row) {
- const panel = document.createElement('div');
- panel.className = 'smd-edit-panel';
-
- const pwLabel = document.createElement('label');
- pwLabel.textContent = i18n.t('dialogs.password', 'Password');
- const pwInput = document.createElement('input');
- pwInput.type = 'password';
- pwInput.className = 'smd-edit-input';
- pwInput.placeholder = i18n.t('share.passwordPlaceholder', 'Leave empty to keep unchanged');
-
- const expLabel = document.createElement('label');
- expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
- const expInput = document.createElement('input');
- expInput.type = 'date';
- expInput.className = 'smd-edit-input';
- if (entry.share.expires_at) {
- expInput.value = new Date(entry.share.expires_at * 1000).toISOString().slice(0, 10);
- }
-
- const actionsDiv = document.createElement('div');
- actionsDiv.className = 'smd-edit-panel-actions';
-
- const cancelBtn = document.createElement('button');
- cancelBtn.className = 'btn btn-secondary';
- cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
- cancelBtn.addEventListener('click', () => panel.remove());
-
- const saveBtn = document.createElement('button');
- saveBtn.className = 'btn btn-primary';
- saveBtn.textContent = i18n.t('actions.save', 'Save');
- saveBtn.addEventListener('click', () => {
- entry._op = 'edit';
- entry._draft = {
- name: entry.share.item_name || '',
- password: pwInput.value || null,
- expires_at: expInput.value || null
- };
- panel.remove();
- this._refreshLinks();
- });
-
- actionsDiv.appendChild(cancelBtn);
- actionsDiv.appendChild(saveBtn);
-
- panel.appendChild(pwLabel);
- panel.appendChild(pwInput);
- panel.appendChild(expLabel);
- panel.appendChild(expInput);
- panel.appendChild(actionsDiv);
-
- void row; // row is unused — panel is inserted via row.after() in caller
- return panel;
- },
-
- /**
- * @param {HTMLButtonElement} newLinkBtn
- * @param {HTMLElement} formWrapper
- * @returns {HTMLElement}
- */
- _buildNewLinkForm(newLinkBtn, formWrapper) {
- const inner = document.createElement('div');
-
- const nameLabel = document.createElement('label');
- nameLabel.textContent = i18n.t('share.linkName', 'Link name');
- const nameInput = document.createElement('input');
- nameInput.type = 'text';
- nameInput.className = 'smd-edit-input';
- nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Optional name');
-
- const pwToggleLabel = document.createElement('label');
- pwToggleLabel.className = 'smd-pw-toggle';
- const pwCheckbox = document.createElement('input');
- pwCheckbox.type = 'checkbox';
- pwToggleLabel.appendChild(pwCheckbox);
- pwToggleLabel.appendChild(document.createTextNode(` ${i18n.t('share.addPassword', 'Add password')}`));
-
- const pwInput = document.createElement('input');
- pwInput.type = 'password';
- pwInput.className = 'smd-edit-input hidden';
- pwInput.placeholder = i18n.t('dialogs.password', 'Password');
- pwCheckbox.addEventListener('change', () => {
- pwInput.classList.toggle('hidden', !pwCheckbox.checked);
- });
-
- const expLabel = document.createElement('label');
- expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
- const expInput = document.createElement('input');
- expInput.type = 'date';
- expInput.className = 'smd-edit-input';
-
- const actionsDiv = document.createElement('div');
- actionsDiv.className = 'smd-new-link-form-actions';
-
- const cancelBtn = document.createElement('button');
- cancelBtn.className = 'btn btn-secondary';
- cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
- cancelBtn.addEventListener('click', () => {
- formWrapper.classList.add('hidden');
- newLinkBtn.classList.remove('hidden');
- });
-
- const addBtn = document.createElement('button');
- addBtn.className = 'btn btn-primary';
- addBtn.textContent = i18n.t('share.addLink', 'Add link');
- addBtn.addEventListener('click', () => {
- /** @type {DraftLink} */
- const draft = {
- name: nameInput.value.trim(),
- password: pwCheckbox.checked ? pwInput.value || null : null,
- expires_at: expInput.value || null
- };
- this._newLinks.push(draft);
- this._refreshLinks();
-
- // Reset form
- nameInput.value = '';
- pwCheckbox.checked = false;
- pwInput.value = '';
- pwInput.classList.add('hidden');
- expInput.value = '';
-
- formWrapper.classList.add('hidden');
- newLinkBtn.classList.remove('hidden');
- });
-
- actionsDiv.appendChild(cancelBtn);
- actionsDiv.appendChild(addBtn);
-
- inner.appendChild(nameLabel);
- inner.appendChild(nameInput);
- inner.appendChild(pwToggleLabel);
- inner.appendChild(pwInput);
- inner.appendChild(expLabel);
- inner.appendChild(expInput);
- inner.appendChild(actionsDiv);
-
- return inner;
- },
-
// ── Apply ──────────────────────────────────────────────────────────────────
/**
@@ -911,6 +831,8 @@ const shareModal = {
try {
// ── Grants ─────────────────────────────────────────────────────────
for (const m of this._localMembers) {
+ // Convert YYYY-MM-DD from date input to ISO-8601 datetime (midnight UTC).
+ const expiresIso = m.expires_at ? new Date(`${m.expires_at}T00:00:00Z`).toISOString() : null;
if (m._op === 'remove') {
// Revoke every individual grant for this subject (one per permission).
for (const g of m._grants) {
@@ -920,13 +842,15 @@ const shareModal = {
await grants.updateRole({
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
resource: { type: itemType, id: item.id },
- role: m.role
+ role: m.role,
+ expires_at: expiresIso
});
} else if (m._op === 'new') {
await grants.createGrant({
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
resource: { type: itemType, id: item.id },
- role: m.role
+ role: m.role,
+ expires_at: expiresIso
});
}
}
@@ -939,8 +863,7 @@ const shareModal = {
const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null;
await fileSharing.updateSharedLink(e.share.id, {
password: e._draft.password,
- expires_at: expiresTs,
- permissions: null
+ expires_at: expiresTs
});
}
}
@@ -970,6 +893,7 @@ const shareModal = {
ui.setSharedVisualState(item.id, itemType, hasAnyShare);
Modal.close(true);
+ this._onApplied?.();
} catch (err) {
console.error('shareModal._applyAll error:', err);
if (Modal.confirmBtn) Modal.confirmBtn.disabled = false;
diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js
index 84b069a3..2b328dc1 100644
--- a/static/js/core/formatters.js
+++ b/static/js/core/formatters.js
@@ -138,6 +138,36 @@ function normalizeDateBucket(value) {
return String(date.getFullYear());
}
+/**
+ * Normalize a future expiry value into a human-readable bucket label.
+ * Buckets (soonest-first): Expired | Tomorrow | In less than 7 days | In less than 30 days | | No expiration
+ *
+ * Accepts the same input types as `normalizeDateBucket`.
+ *
+ * @param {string | number | Date | null | undefined} value
+ * @returns {string}
+ */
+function normalizeExpiryBucket(value) {
+ if (value === null || value === undefined) {
+ return i18n.t('expiryBucket.noExpiry', 'No expiration');
+ }
+ /** @type {Date} */
+ let date;
+ if (value instanceof Date) {
+ date = value;
+ } else if (typeof value === 'number') {
+ date = new Date(value < 1e12 ? value * 1000 : value);
+ } else {
+ date = new Date(value);
+ }
+ const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
+ if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired');
+ if (daysUntil <= 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow');
+ if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days');
+ if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days');
+ return String(date.getFullYear());
+}
+
/**
* Maps a file size in bytes to a coarse, human-readable bucket label.
*
@@ -167,4 +197,15 @@ function sizeBucket(bytes) {
return i18n.t('sizeBucket.huge', '> 5 GB');
}
-export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket };
+export {
+ escapeHtml,
+ formatDateShort,
+ formatDateTime,
+ formatFileSize,
+ formatQuotaSize,
+ isEmailValid,
+ isTextViewable,
+ normalizeDateBucket,
+ normalizeExpiryBucket,
+ sizeBucket
+};
diff --git a/static/js/core/icons.js b/static/js/core/icons.js
index 125d945f..679caff1 100644
--- a/static/js/core/icons.js
+++ b/static/js/core/icons.js
@@ -239,6 +239,10 @@ const OxiIcons = {
576,
'M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z'
],
+ infinity: [
+ 640,
+ 'M0 256c0-88.4 71.6-160 160-160 50.4 0 97.8 23.7 128 64l32 42.7 32-42.7c30.2-40.3 77.6-64 128-64 88.4 0 160 71.6 160 160S568.4 416 480 416c-50.4 0-97.8-23.7-128-64l-32-42.7-32 42.7c-30.2 40.3-77.6 64-128 64-88.4 0-160-71.6-160-160zm280 0l-43.2-57.6c-18.1-24.2-46.6-38.4-76.8-38.4-53 0-96 43-96 96s43 96 96 96c30.2 0 58.7-14.2 76.8-38.4L280 256zm80 0l43.2 57.6c18.1 24.2 46.6 38.4 76.8 38.4 53 0 96-43 96-96s-43-96-96-96c-30.2 0-58.7 14.2-76.8 38.4L360 256z'
+ ],
'info-circle': [
512,
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z'
diff --git a/static/js/core/types.js b/static/js/core/types.js
index c30b6b44..d89054a8 100644
--- a/static/js/core/types.js
+++ b/static/js/core/types.js
@@ -46,13 +46,6 @@
* @property {number} sort_date
*/
-/**
- * @typedef {Object} SharePermissions
- * @property {boolean} read
- * @property {boolean} reshare
- * @property {boolean} write
- */
-
/**
* @typedef {Object} ShareItem
* @property {number} access_count
@@ -64,7 +57,6 @@
* @property {string} item_id
* @property {string} item_name
* @property {ItemTypeEnum} item_type
- * @property {SharePermissions} permissions
* @property {string | null} token
* @property {string} url
*/
@@ -76,14 +68,12 @@
* @property {ItemTypeEnum} item_type
* @property {string|null} password
* @property {number|null} expires_at - timestamp
- * @property {SharePermissions|null} permissions
*/
/**
* @typedef {Object} UpdateShare
- * @property {string|null} password
- * @property {number|null} expires_at - timestamp
- * @property {SharePermissions|null} permissions
+ * @property {string|null} [password]
+ * @property {number|null} [expires_at]
*/
/**
@@ -284,11 +274,12 @@
/**
* @typedef {Object} Grant
* @property {string} id
- * @property {number} granted_at
+ * @property {string} granted_at - ISO-8601 datetime string.
* @property {string} granted_by
* @property {Subject} subject
* @property {PermissionTypeEnum} permission
* @property {Resource} resource
+ * @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry.
*/
/**
@@ -333,6 +324,35 @@
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
*/
+/**
+ * One (subject, permissions) entry within an outgoing resource item.
+ * @typedef {Object} OutgoingResourceGrant
+ * @property {string} grant_id
+ * @property {'user'|'token'} subject_type
+ * @property {string} subject_id
+ * @property {string} subject_display - Username (users) or share name (tokens).
+ * @property {'viewer'|'editor'|'admin'} role
+ * @property {string} granted_at - ISO-8601
+ * @property {string|null} [expires_at] - ISO-8601 or absent.
+ * @property {boolean} has_password - True when a token subject has a password set.
+ */
+
+/**
+ * One item returned by `GET /api/grants/outgoing/resources`.
+ * @typedef {Object} OutgoingResourceItem
+ * @property {ResourceTypeEnum} resource_type
+ * @property {string} first_shared_at - ISO-8601 earliest grant date.
+ * @property {FileItem|FolderItem} resource - Full resource details.
+ * @property {OutgoingResourceGrant[]} grants - One entry per (subject, permissions).
+ */
+
+/**
+ * Response for `GET /api/grants/outgoing/resources`.
+ * @typedef {Object} OutgoingResourcesResponse
+ * @property {OutgoingResourceItem[]} items
+ * @property {string|undefined} [next_cursor] - Absent when the last page is reached.
+ */
+
/**
* One item returned by `GET /api/favorites/resources`.
* `resource_type` discriminates the shape of `resource`.
@@ -405,6 +425,7 @@
* @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
+ * @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
*/
/**
diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js
index c0520e40..35a33366 100644
--- a/static/js/features/files/contextMenus.js
+++ b/static/js/features/files/contextMenus.js
@@ -489,24 +489,20 @@ const contextMenus = {
return;
}
- // Use the contents endpoint to get children
- const url = `/api/folders/${effectiveParentId}/contents`;
-
- console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId);
- const response = await fetch(url, { credentials: 'same-origin' });
- if (!response.ok) {
- console.error('Failed to load folders:', response.status);
- return;
- }
-
- const data = await response.json();
- console.log('[Move Dialog] API response:', data);
-
- // The contents endpoint returns an array of child folders
- // The fallback /api/folders returns root folders (home folder itself)
/** @type {FolderItem[]} */
- const folders = Array.isArray(data) ? data : data.folders || [];
- console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
+ const folders = [];
+ let cursor = /** @type {string|null} */ (null);
+ do {
+ const qs = cursor ? `resource_types=folder&cursor=${encodeURIComponent(cursor)}` : 'resource_types=folder';
+ const response = await fetch(`/api/folders/${effectiveParentId}/resources?${qs}`, { credentials: 'same-origin' });
+ if (!response.ok) {
+ console.error('Failed to load folders:', response.status);
+ return;
+ }
+ const data = await response.json();
+ folders.push(...(data.items || []));
+ cursor = data.next_cursor ?? null;
+ } while (cursor);
const folderSelectContainer = document.getElementById('folder-select-container');
const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb');
@@ -719,7 +715,6 @@ const contextMenus = {
app.moveDialogBreadcrumb = [];
app.moveDialogCurrentFolderId = app.userHomeFolderId || null;
- // Use loadMoveDialogFolders which uses /api/folders/{id}/contents
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
},
diff --git a/static/js/features/sharing/fileSharing.js b/static/js/features/sharing/fileSharing.js
index fd54d0e8..5c873a69 100644
--- a/static/js/features/sharing/fileSharing.js
+++ b/static/js/features/sharing/fileSharing.js
@@ -35,12 +35,7 @@ const fileSharing = {
item_name: options.item_name || null,
item_type: itemType,
password: options.password || null,
- expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null,
- permissions: options.permissions || {
- read: true,
- write: false,
- reshare: false
- }
+ expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null
};
const res = await fetch('/api/shares', {
@@ -113,12 +108,11 @@ const fileSharing = {
/**
* Update a shared link
* @param {string} shareId
- * @param {UpdateShare} updateData - { permissions, password, expires_at }
+ * @param {UpdateShare} updateData - { password, expires_at }
* @returns {Promise