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} Updated ShareDto */ async updateSharedLink(shareId, updateData) { const body = {}; - if (updateData.permissions) body.permissions = updateData.permissions; if (updateData.password !== undefined) body.password = updateData.password; if (updateData.expires_at !== undefined) body.expires_at = updateData.expires_at; @@ -154,6 +148,20 @@ const fileSharing = { } }, + /** + * Fetch a single share by its UUID and return the full ShareItem. + * Used to resolve a token's URL on demand (lazy fetch on copy-link click). + * @param {string} shareId + * @returns {Promise} + */ + async getShareById(shareId) { + const res = await fetch(`/api/shares/${shareId}`, { + headers: this._headers(false) + }); + if (!res.ok) throw new Error(`getShareById ${shareId}: HTTP ${res.status}`); + return res.json(); + }, + /** * Copy a shared link to clipboard * @param {string} url diff --git a/static/js/model/grants.js b/static/js/model/grants.js index 4eddcbe7..f3b6b7de 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -1,5 +1,5 @@ /** - * @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js' + * @import {Grant, ResourceTypeEnum, SharedWithMeResponse, OutgoingResourcesResponse} from '../core/types.js' */ import { getCsrfHeaders } from '../core/csrf.js'; @@ -110,6 +110,32 @@ const grants = { return response.json(); }, + /** + * Fetch a cursor-paginated list of resources the current user has shared + * with others, with full file / folder metadata resolved server-side. + * + * @param {object} [opts] + * @param {number} [opts.limit] - Max items per page (1–200, default 50). + * @param {string} [opts.cursor] - Opaque cursor from a previous call. + * @param {string} [opts.orderBy] - Sort: 'first_shared_at' | 'name' | 'type' | 'subject'. + * @param {boolean} [opts.reverse] - Reverse sort order. + * @returns {Promise} + */ + async fetchMySharesPage({ limit = 50, cursor, orderBy, reverse = false } = {}) { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + if (orderBy) params.set('sort_by', orderBy); + if (reverse) params.set('reverse', 'true'); + + const response = await fetch(`/api/grants/outgoing/resources?${params}`); + + if (!response.ok) { + throw new Error(`Failed to fetch my shares: HTTP ${response.status}`); + } + + return response.json(); + }, + /** * Fetch all grants on a specific resource (for the "Manage sharing" panel). * Refreshes the outgoingGrants cache for this resource. diff --git a/static/js/utils/expiryChip.js b/static/js/utils/expiryChip.js new file mode 100644 index 00000000..248412b6 --- /dev/null +++ b/static/js/utils/expiryChip.js @@ -0,0 +1,93 @@ +/** + * buildExpiryChip — shared compact expiry editor chip. + * + * Chip states: + * • "∞ No expiry" — dashed border, faint text (value is null) + * • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set) + * + * Clicking the chip toggles to an inline . + * CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-date-input) + * live in shareModal.css. + */ + +import { i18n } from '../core/i18n.js'; + +/** + * Format a YYYY-MM-DD string for display ("Dec 31, 2026"). + * @param {string} dateStr + * @returns {string} + */ +export function formatExpiryDate(dateStr) { + const d = new Date(`${dateStr}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/** + * Build an interactive expiry chip. + * @param {string|null} initialValue YYYY-MM-DD or null + * @param {(v: string|null) => void} onChange called whenever the value changes + * @returns {HTMLElement} + */ +export function buildExpiryChip(initialValue, onChange) { + let current = initialValue; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const dateInput = document.createElement('input'); + dateInput.type = 'date'; + dateInput.className = 'smd-expiry-date-input hidden'; + + const updateChip = () => { + if (current) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${formatExpiryDate(current)}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + current = null; + onChange(null); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noExpiry', 'No expiry')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + if (current) dateInput.value = current; + dateInput.classList.remove('hidden'); + dateInput.focus(); + }); + + const confirm = () => { + const val = dateInput.value || null; + current = val; + onChange(val); + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + updateChip(); + }; + dateInput.addEventListener('blur', confirm); + dateInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(dateInput); + return wrap; +} diff --git a/static/js/utils/passwordChip.js b/static/js/utils/passwordChip.js new file mode 100644 index 00000000..f5a8d480 --- /dev/null +++ b/static/js/utils/passwordChip.js @@ -0,0 +1,88 @@ +/** + * buildPasswordChip — shared inline password editor chip. + * + * States: + * • "🔓 No password" — unset (default) + * • "🔒 Password ×" — set; × clears it + * + * Clicking the chip shows a hidden . + * Blur / Enter confirms; Escape cancels. + * CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-chip--set, + * .smd-expiry-date-input) live in shareModal.css. + */ + +import { i18n } from '../core/i18n.js'; + +/** + * @param {boolean} initialHasPassword + * @param {(v: string) => void} onChange '' = remove, non-empty = set new password + * @returns {HTMLElement} + */ +export function buildPasswordChip(initialHasPassword, onChange) { + let hasPassword = initialHasPassword; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.className = 'smd-expiry-date-input hidden'; + pwInput.placeholder = i18n.t('dialogs.password', 'Password'); + pwInput.autocomplete = 'new-password'; + + const updateChip = () => { + if (hasPassword) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${i18n.t('share.passwordProtected', 'Password')}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + hasPassword = false; + onChange(''); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noPassword', 'No password')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + pwInput.value = ''; + pwInput.classList.remove('hidden'); + pwInput.focus(); + }); + + const confirm = () => { + const val = pwInput.value; + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + if (val) { + hasPassword = true; + onChange(val); + } + updateChip(); + }; + + pwInput.addEventListener('blur', confirm); + pwInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(pwInput); + return wrap; +} diff --git a/static/js/views/myShares/mySharesView.js b/static/js/views/myShares/mySharesView.js new file mode 100644 index 00000000..257d9c8d --- /dev/null +++ b/static/js/views/myShares/mySharesView.js @@ -0,0 +1,239 @@ +/** + * OxiCloud – "My Shares" view. + * + * Renders resources the current user has shared with others, using the + * cursor-paginated `GET /api/grants/outgoing/resources` endpoint. + * + * Group-by modes exposed to the navigation toolbar: + * 'items' — one card per resource (sort_by=type) [default / None] + * 'sharedWith' — swimlanes per subject (sort_by=subject) + * + * The "None" option (key='') from the toolbar maps to the default 'items' mode. + */ + +import { ui } from '../../app/ui.js'; +import { MySharesList } from '../../components/mySharesList.js'; +import { shareModal } from '../../components/shareModal.js'; +import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; +import { grants } from '../../model/grants.js'; + +/** @import {FileItem, FolderItem} from '../../core/types.js' */ + +/** + * @typedef {{ key: string, label: string, orderBy: string }} GroupByDef + * @typedef {'items'|'sharedWith'} ViewMode + */ + +/** + * @type {{ [key: string]: { orderBy: string, viewMode: ViewMode } }} + */ +const MODE_MAP = { + '': { orderBy: 'type', viewMode: 'items' }, + items: { orderBy: 'type', viewMode: 'items' }, + sharedWith: { orderBy: 'subject', viewMode: 'sharedWith' } +}; + +/** @type {GroupByDef[]} */ +const GROUP_BY_DEFS = [ + { + key: '', + get label() { + return i18n.t('groupby.byFiles', 'By files'); + }, + orderBy: 'type' + }, + { + key: 'sharedWith', + get label() { + return i18n.t('groupby.sharedWith', 'Shared with'); + }, + orderBy: 'subject' + } +]; + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'ms-load-more-wrapper'; + +const mySharesView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + /** @type {MySharesList|null} */ + _component: null, + + /** @type {string} */ + _groupBy: '', + + /** @type {boolean} */ + _reversed: false, + + // ── Public API ──────────────────────────────────────────────────────────── + + /** @returns {GroupByDef[]} */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Called by navigation.js when the user picks a pill. + * Empty string '' maps to the default items mode. + * @param {string} key + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * Flip sort direction and reload from page 1. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (this._reversed === reversed) return; + this._reversed = reversed; + viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + async init() { + this._nextCursor = null; + this._loading = false; + const saved = viewPrefs.load('shared'); + this._groupBy = saved.groupBy || ''; + this._reversed = saved.reversed; + + this._ensureLoadMoreButton(); + + ui.resetFilesList(); + ui.updateBreadcrumb(); + + const filesList = document.getElementById('files-list'); + if (filesList) { + if (!this._component) { + this._component = new MySharesList(filesList, { + onResourceOpen: (resource, resourceType) => { + if (resourceType === 'folder') { + ui.openItem(resource); + } else { + // File: navigate to the parent folder in Files section. + const file = /** @type {FileItem} */ (resource); + const parts = (file.path || '').split('/').filter(Boolean); + const parentName = parts.length >= 2 ? parts[parts.length - 2] : ''; + ui.openItem(/** @type {FolderItem} */ ({ id: file.folder_id, name: parentName })); + } + }, + onShareEdit: (resource, resourceType) => { + shareModal.open(resource, /** @type {'file'|'folder'} */ (resourceType), () => { + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }); + } + }); + } + } + + await this._loadPage(); + }, + + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.destroy(filesList); + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + async _loadPage() { + if (this._loading) return; + this._loading = true; + + const isFirstPage = this._nextCursor === null; + + try { + const mode = MODE_MAP[this._groupBy] ?? MODE_MAP['']; + + const data = await grants.fetchMySharesPage({ + limit: 50, + cursor: this._nextCursor ?? undefined, + orderBy: mode.orderBy, + reverse: this._reversed + }); + + this._nextCursor = data.next_cursor ?? null; + + if (data.items.length === 0 && isFirstPage) { + ui.showError(` + +

${i18n.t('myshares.emptyStateTitle', "You haven't shared anything yet")}

+

${i18n.t('myshares.emptyStateDesc', 'Items you share with others will appear here')}

+ `); + this._setLoadMoreVisible(false); + return; + } + + if (isFirstPage) { + this._component?.render(data.items, mode.viewMode); + } else { + this._component?.append(data.items, mode.viewMode); + } + + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.init(filesList); + + this._setLoadMoreVisible(!!this._nextCursor); + } catch (err) { + ui.showError(` + +

${i18n.t('errors_loadFailed', 'Failed to load items')}

+ `); + console.error('mySharesView: load error', err); + } finally { + this._loading = false; + } + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'ms-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'ms-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('myshares.loadMore', 'Load more'); + btn.addEventListener('click', () => this._loadPage()); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); + }, + + /** @param {boolean} visible */ + _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); + } +}; + +export { mySharesView }; diff --git a/static/js/views/shared/sharedView.js b/static/js/views/shared/sharedView.js deleted file mode 100644 index 4fe18e4e..00000000 --- a/static/js/views/shared/sharedView.js +++ /dev/null @@ -1,672 +0,0 @@ -/** - * OxiCloud - Shared View Component - * In-app shared files view. All operations go through the backend API. - */ - -import { switchToFilesSection } from '../../app/navigation.js'; -import { ui } from '../../app/ui.js'; -import { getCsrfHeaders } from '../../core/csrf.js'; -import { formatDateShort, isEmailValid } from '../../core/formatters.js'; -import { i18n } from '../../core/i18n.js'; -import { fileSharing } from '../../features/sharing/fileSharing.js'; - -/** @import {ShareItem} from '../../core/types.js' */ - -const TTL = 5 * 60 * 1000; // 5 min - -const sharedView = { - // State - - /** @type {Array} */ - items: [], - - _expires: 0, - - /** @type {Map} key = "file:" | "folder:" */ - _knownItemsId: new Map(), - - /** @type {Array} */ - filteredItems: [], - - /** @type {ShareItem | null} */ - currentItem: null, - - /** Auth header helper — tokens are in HttpOnly cookies now */ - _headers(json = false) { - const h = { ...getCsrfHeaders() }; - if (json) h['Content-Type'] = 'application/json'; - return h; - }, - - async init() { - console.log('Initializing shared view component (API-backed)'); - await this.loadItems(); - }, - - show() { - this.displayUI(); - this.attachEventListeners(); - this.filterAndSortItems(); - const c = document.getElementById('shared-container'); - if (c) c.classList.remove('hidden'); - }, - - hide() { - const c = document.getElementById('shared-container'); - if (c) c.classList.add('hidden'); - }, - - /** - * tells if item_id is shared - * - * @param {string} id the item_id - * @param {string} type folder|file - * @returns {boolean} true if this item is shared - */ - isShared(id, type) { - return this._knownItemsId.has(`${type}:${id}`); - }, - - // Load shared items from backend API, - // TODO cache entries to minimize calls - /** - * load shared items - * - * @param {boolean} force ignore cache - */ - async loadItems(force = false) { - if (this._expires > Date.now() && !force) return; - - try { - const res = await fetch('/api/shares?page=1&per_page=1000', { - headers: this._headers() - }); - if (res.ok) { - const data = await res.json(); - this.items = data.items || []; - } else { - this.items = []; - } - this.filteredItems = { ...this.items }; - this._knownItemsId.clear(); - this.items.forEach((item) => { - this._knownItemsId.set(`${item.item_type}:${item.item_id}`, true); - }); - this._expires = Date.now() + TTL; - } catch (err) { - console.error('Error loading shared items:', err); - this.items = []; - } - }, - - // Create and display the shared view UI - displayUI() { - const contentArea = document.querySelector('.content-area'); - - let container = document.getElementById('shared-container'); - if (!container) { - container = document.createElement('div'); - container.id = 'shared-container'; - container.className = 'shared-view-container'; - if (contentArea) contentArea.appendChild(container); - } - - container.innerHTML = ` -
-
-
- -
-
All
-
Files
-
Folders
-
-
-
- -
-
Sort by date
-
Sort by name
-
Sort by expiration
-
-
-
-
- - - - - - - - - - - `; - - i18n.translateElement(container); - }, - - // Attach event listeners - attachEventListeners() { - // Custom dropdown logic for filter-type - this._initCustomSelect('filter-type-wrapper', 'filter-type-toggle', 'filter-type-dropdown'); - // Custom dropdown logic for sort-by - this._initCustomSelect('sort-by-wrapper', 'sort-by-toggle', 'sort-by-dropdown'); - - // Close dropdowns when clicking outside - document.addEventListener('click', (e) => { - document.querySelectorAll('.shared-custom-select.open').forEach((sel) => { - if (!(e.target instanceof Node)) return; - if (!sel.contains(e.target)) sel.classList.remove('open'); - }); - }); - - // Share dialog (sharedView-specific IDs) - const shareDialog = document.getElementById('shared-view-edit-dialog'); - if (shareDialog) { - const closeBtn = shareDialog.querySelector('.close-dialog-btn'); - if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog()); - const copyLinkBtn = document.getElementById('sv-copy-link-btn'); - if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink()); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - if (enablePw) - enablePw.addEventListener('change', () => { - if (pwField) { - pwField.disabled = !enablePw.checked; - if (enablePw.checked) pwField.focus(); - } - }); - const genPwBtn = document.getElementById('sv-generate-password'); - if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword()); - const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration')); - const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration')); - if (enableExp) - enableExp.addEventListener('change', () => { - if (expField) { - expField.disabled = !enableExp.checked; - if (enableExp.checked) expField.focus(); - } - }); - const updateBtn = document.getElementById('sv-update-share-btn'); - if (updateBtn) updateBtn.addEventListener('click', () => this.updateSharedItem()); - const removeBtn = document.getElementById('sv-remove-share-btn'); - if (removeBtn) removeBtn.addEventListener('click', () => this.removeSharedItem()); - } - - // Notification dialog (sharedView-specific IDs) - const notifDialog = document.getElementById('sv-notification-dialog'); - if (notifDialog) { - const closeBtn = notifDialog.querySelector('.close-dialog-btn'); - if (closeBtn) closeBtn.addEventListener('click', () => this.closeNotificationDialog()); - const sendBtn = document.getElementById('sv-send-notification-btn'); - if (sendBtn) sendBtn.addEventListener('click', () => this.sendNotification()); - } - - // "Go to Files" button in empty state - const goToFilesBtn = document.getElementById('go-to-files-btn'); - if (goToFilesBtn) { - goToFilesBtn.addEventListener('click', () => { - if (switchToFilesSection) switchToFilesSection(); - }); - } - }, - - // Initialize a custom select dropdown - /** - * - * @param {string} wrapperId - * @param {string} toggleId - * @param {string} dropdownId - * @returns - */ - _initCustomSelect(wrapperId, toggleId, dropdownId) { - const wrapper = document.getElementById(wrapperId); - const toggle = document.getElementById(toggleId); - const dropdown = document.getElementById(dropdownId); - if (!wrapper || !toggle || !dropdown) return; - - toggle.addEventListener('click', (e) => { - e.stopPropagation(); - // Close other open selects - document.querySelectorAll('.shared-custom-select.open').forEach((sel) => { - if (sel !== wrapper) sel.classList.remove('open'); - }); - wrapper.classList.toggle('open'); - }); - - dropdown.querySelectorAll('.shared-select-option').forEach((option) => { - option.addEventListener('click', (e) => { - e.stopPropagation(); - // Update active state - dropdown.querySelectorAll('.shared-select-option').forEach((o) => { - o.classList.remove('active'); - }); - option.classList.add('active'); - // Update label - const label = toggle.querySelector('.shared-select-label'); - if (label) label.textContent = option.textContent; - // Close dropdown - wrapper.classList.remove('open'); - // Trigger filter - this.filterAndSortItems(); - }); - }); - }, - - // Filter and sort items - filterAndSortItems() { - const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-dropdown .shared-select-option.active')); - const sortByActive = /** @type {HTMLDivElement} */ (document.querySelector('#sort-by-dropdown .shared-select-option.active')); - - const type = filterTypeActive ? filterTypeActive.dataset.value : 'all'; - const sort = sortByActive ? sortByActive.dataset.value : 'date'; - - // Use the main top-bar search input - const searchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input')); - const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; - - this.filteredItems = this.items.filter((item) => { - if (type !== 'all' && item.item_type !== type) return false; - const name = (item.item_name || item.item_id || '').toLowerCase(); - return name.includes(searchTerm); - }); - - this.filteredItems.sort((a, b) => { - if (sort === 'name') { - return (a.item_name || a.item_id || '').localeCompare(b.item_name || b.item_id || ''); - } else if (sort === 'date') { - return (b.created_at || 0) - (a.created_at || 0); - } else if (sort === 'expiration') { - if (!a.expires_at && !b.expires_at) return 0; - if (!a.expires_at) return 1; - if (!b.expires_at) return -1; - return a.expires_at - b.expires_at; - } - return 0; - }); - - this.displaySharedItems(); - }, - - // Display items in the table - displaySharedItems() { - const sharedItemsList = document.getElementById('shared-items-list'); - const emptyState = document.getElementById('empty-shared-state'); - const listContainer = document.querySelector('.shared-list-container'); - - if (!sharedItemsList || !emptyState || !listContainer) return; - sharedItemsList.innerHTML = ''; - - if (this.filteredItems.length === 0) { - emptyState.classList.remove('hidden'); - listContainer.classList.add('hidden'); - return; - } - - emptyState.classList.add('hidden'); - listContainer.classList.remove('hidden'); - - this.filteredItems.forEach((item) => { - const row = document.createElement('tr'); - const displayName = item.item_name || item.item_id || 'Unknown'; - - const nameCell = document.createElement('td'); - nameCell.className = 'shared-item-name'; - const iconSpan = document.createElement('span'); - iconSpan.className = 'item-icon'; - iconSpan.textContent = item.item_type === 'file' ? '📄' : '📁'; - const nameSpan = document.createElement('span'); - nameSpan.textContent = displayName; - nameCell.appendChild(iconSpan); - nameCell.appendChild(nameSpan); - - const typeCell = document.createElement('td'); - typeCell.textContent = item.item_type === 'file' ? i18n.t('shared_typeFile', 'File') : i18n.t('shared_typeFolder', 'Folder'); - - const dateCell = document.createElement('td'); - dateCell.textContent = formatDateShort(item.created_at); - - const expCell = document.createElement('td'); - expCell.textContent = item.expires_at ? formatDateShort(item.expires_at) : i18n.t('shared_noExpiration', 'No expiration'); - - const permCell = document.createElement('td'); - const perms = []; - if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read')); - if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write')); - if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare')); - permCell.textContent = perms.join(', ') || 'Read'; - - const pwCell = document.createElement('td'); - pwCell.textContent = item.has_password ? i18n.t('shared_hasPassword', 'Yes') : i18n.t('shared_noPassword', 'No'); - - const actionsCell = document.createElement('td'); - actionsCell.className = 'shared-item-actions'; - - const editBtn = document.createElement('button'); - editBtn.className = 'action-btn edit-btn'; - editBtn.innerHTML = '✏️'; - editBtn.title = i18n.t('shared_editShare', 'Edit Share'); - editBtn.addEventListener('click', () => this.openShareDialog(item)); - - const notifyBtn = document.createElement('button'); - notifyBtn.className = 'action-btn notify-btn'; - notifyBtn.innerHTML = '📧'; - notifyBtn.title = i18n.t('shared_notifyShare', 'Notify Someone'); - notifyBtn.addEventListener('click', () => this.openNotificationDialog(item)); - - const copyBtn = document.createElement('button'); - copyBtn.className = 'action-btn copy-btn'; - copyBtn.innerHTML = '📋'; - copyBtn.title = i18n.t('shared_copyLink', 'Copy Link'); - copyBtn.addEventListener('click', () => { - navigator.clipboard - .writeText(item.url) - .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success')) - .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error')); - }); - - const rmBtn = document.createElement('button'); - rmBtn.className = 'action-btn remove-btn'; - rmBtn.innerHTML = '🗑️'; - rmBtn.title = i18n.t('shared_removeShare', 'Remove Share'); - rmBtn.addEventListener('click', () => { - this.currentItem = item; - this.removeSharedItem(); - }); - - actionsCell.append(editBtn, notifyBtn, copyBtn, rmBtn); - row.append(nameCell, typeCell, dateCell, expCell, permCell, pwCell, actionsCell); - sharedItemsList.appendChild(row); - }); - }, - - // Open share dialog - /** - * - * @param {ShareItem} item - * @returns {void} - */ - openShareDialog(item) { - this.currentItem = item; - const shareDialog = document.getElementById('shared-view-edit-dialog'); - const dn = item.item_name || item.item_id || 'Unknown'; - - const iconEl = document.getElementById('sv-dialog-icon'); - const nameEl = document.getElementById('sv-dialog-name'); - const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url')); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration')); - const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration')); - const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read')); - const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write')); - const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare')); - - if (!shareDialog) return; - if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; - if (nameEl) nameEl.textContent = dn; - if (urlEl) urlEl.value = item.url || ''; - - if (permRead) permRead.checked = item.permissions?.read !== false; - if (permWrite) permWrite.checked = !!item.permissions?.write; - if (permReshare) permReshare.checked = !!item.permissions?.reshare; - - if (enablePw) { - enablePw.checked = item.has_password; - if (pwField) { - pwField.disabled = !enablePw.checked; - pwField.value = ''; - } - } - if (enableExp) { - enableExp.checked = !!item.expires_at; - if (expField) { - expField.disabled = !enableExp.checked; - expField.value = item.expires_at ? new Date(item.expires_at * 1000).toISOString().split('T')[0] : ''; - } - } - - shareDialog.classList.remove('hidden'); - }, - - closeShareDialog() { - const d = document.getElementById('shared-view-edit-dialog'); - if (d) d.classList.add('hidden'); - this.currentItem = null; - }, - - /** - * - * @param {ShareItem} item - * @returns {void} - */ - openNotificationDialog(item) { - this.currentItem = item; - const dn = item.item_name || item.item_id || 'Unknown'; - const d = document.getElementById('sv-notification-dialog'); - const iconEl = document.getElementById('sv-notify-dialog-icon'); - const nameEl = document.getElementById('sv-notify-dialog-name'); - const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email')); - const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message')); - - if (!d) return; - if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; - if (nameEl) nameEl.textContent = dn; - if (emailEl) emailEl.value = ''; - if (msgEl) msgEl.value = ''; - d.classList.remove('hidden'); - }, - - closeNotificationDialog() { - const d = document.getElementById('sv-notification-dialog'); - if (d) d.classList.add('hidden'); - this.currentItem = null; - }, - - copyShareLink() { - const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url')); - if (!el) return; - navigator.clipboard - .writeText(el.value) - .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success')) - .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error')); - }, - - // Generate secure password with crypto API - generatePassword() { - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - if (!pwField || !enablePw) return; - - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; - const array = new Uint32Array(16); - crypto.getRandomValues(array); - let password = ''; - for (let i = 0; i < 16; i++) { - password += chars[array[i] % chars.length]; - } - pwField.value = password; - enablePw.checked = true; - pwField.disabled = false; - }, - - // Update share via API - async updateSharedItem() { - if (!this.currentItem) return; - - const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read')); - const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write')); - const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare')); - const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password')); - const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password')); - const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration')); - const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration')); - - const body = { - permissions: { - read: permRead ? permRead.checked : true, - write: permWrite ? permWrite.checked : false, - reshare: permReshare ? permReshare.checked : false - }, - password: enablePw?.checked && pwField?.value ? pwField.value : null, - expires_at: enableExp?.checked && expField?.value ? Math.floor(new Date(expField.value).getTime() / 1000) : null - }; - - try { - // FIXME: redundance with fileSharing - const res = await fetch(`/api/shares/${this.currentItem.id}`, { - method: 'PUT', - headers: this._headers(true), - body: JSON.stringify(body) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || `Server error ${res.status}`); - } - ui.showNotification(i18n.t('shared_itemUpdated', 'Share settings updated'), 'success'); - } catch (err) { - console.error('Error updating share:', err); - ui.showNotification(/** @type {Error} */ (err).message || 'Error updating share', 'error'); - } - // update UI - ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true); - this.closeShareDialog(); - await this.loadItems(true); - this.filterAndSortItems(); - }, - - // Remove share via API - async removeSharedItem() { - if (!this.currentItem) return; - - try { - // FIXME: redundance with fileSharing - const res = await fetch(`/api/shares/${this.currentItem.id}`, { - method: 'DELETE', - headers: this._headers() - }); - if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`); - ui.showNotification(i18n.t('shared_itemRemoved', 'Share removed'), 'success'); - } catch (err) { - console.error('Error removing share:', err); - ui.showNotification('Error removing share', 'error'); - } - - this.closeShareDialog(); - await this.loadItems(true); - this.filterAndSortItems(); - // update UI - ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, this.isShared(this.currentItem.item_id, this.currentItem.item_type)); - }, - - // Send notification (stub) - sendNotification() { - if (!this.currentItem) return; - const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email')); - const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message')); - const email = emailEl ? emailEl.value.trim() : ''; - const message = msgEl ? msgEl.value.trim() : ''; - - if (!email || !isEmailValid(email)) { - ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error'); - return; - } - - if (fileSharing?.sendShareNotification) { - fileSharing - .sendShareNotification(this.currentItem.url, email, message) - .then(() => { - this.closeNotificationDialog(); - ui.showNotification(i18n.t('shared_notificationSent', 'Notification sent'), 'success'); - }) - .catch(() => ui.showNotification(i18n.t('shared_notificationFailed', 'Failed to send notification'), 'error')); - } - } -}; - -export { sharedView }; diff --git a/static/locales/ar.json b/static/locales/ar.json index 7a433772..940e18d7 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -723,7 +723,9 @@ "modifiedAt": "تاريخ التعديل", "createdAt": "تاريخ الإنشاء", "size": "الحجم", - "favoriteDate": "تاريخ المفضلة" + "favoriteDate": "تاريخ المفضلة", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "اليوم", diff --git a/static/locales/de.json b/static/locales/de.json index afee44b1..24d1a2c7 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -723,7 +723,9 @@ "modifiedAt": "Änderungsdatum", "createdAt": "Erstellungsdatum", "size": "Größe", - "favoriteDate": "Datum der Markierung" + "favoriteDate": "Datum der Markierung", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Heute", diff --git a/static/locales/en.json b/static/locales/en.json index e1889828..5e1fa75c 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -714,6 +714,8 @@ }, "groupby": { "none": "None", + "byFiles": "By files", + "sharedWith": "Shared with", "title": "Group by", "type": "Type", "type.folders": "Folders", diff --git a/static/locales/es.json b/static/locales/es.json index 9a29e8ae..a0deed14 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -723,7 +723,9 @@ "modifiedAt": "Fecha de modificación", "createdAt": "Fecha de creación", "size": "Tamaño", - "favoriteDate": "Fecha de favorito" + "favoriteDate": "Fecha de favorito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Hoy", diff --git a/static/locales/fa.json b/static/locales/fa.json index 83c636ef..e4ffcce5 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -723,7 +723,9 @@ "modifiedAt": "تاریخ تغییر", "createdAt": "تاریخ ایجاد", "size": "اندازه", - "favoriteDate": "تاریخ مورد علاقه" + "favoriteDate": "تاریخ مورد علاقه", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "امروز", diff --git a/static/locales/fr.json b/static/locales/fr.json index 51bf3a90..b410050f 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -723,7 +723,9 @@ "accessedAt": "Date d'accès", "modifiedAt": "Date de modification", "createdAt": "Date de création", - "size": "Taille" + "size": "Taille", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Aujourd'hui", diff --git a/static/locales/hi.json b/static/locales/hi.json index fb827113..011dbec5 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -723,7 +723,9 @@ "modifiedAt": "संशोधन की तारीख", "createdAt": "बनाने की तारीख", "size": "आकार", - "favoriteDate": "पसंदीदा की तारीख" + "favoriteDate": "पसंदीदा की तारीख", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "आज", diff --git a/static/locales/it.json b/static/locales/it.json index bf8eb86f..0977c1f3 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -723,7 +723,9 @@ "modifiedAt": "Data di modifica", "createdAt": "Data di creazione", "size": "Dimensione", - "favoriteDate": "Data preferito" + "favoriteDate": "Data preferito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Oggi", diff --git a/static/locales/ja.json b/static/locales/ja.json index 720eb836..17b3d1fc 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -723,7 +723,9 @@ "modifiedAt": "更新日", "createdAt": "作成日", "size": "サイズ", - "favoriteDate": "お気に入り登録日" + "favoriteDate": "お気に入り登録日", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今日", diff --git a/static/locales/ko.json b/static/locales/ko.json index a4428fbd..131b5805 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -723,7 +723,9 @@ "modifiedAt": "수정 날짜", "createdAt": "생성 날짜", "size": "크기", - "favoriteDate": "즐겨찾기 날짜" + "favoriteDate": "즐겨찾기 날짜", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "오늘", diff --git a/static/locales/nl.json b/static/locales/nl.json index 4b4f3c72..397bc034 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -723,7 +723,9 @@ "modifiedAt": "Wijzigingsdatum", "createdAt": "Aanmaakdatum", "size": "Grootte", - "favoriteDate": "Favoritendatum" + "favoriteDate": "Favoritendatum", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Vandaag", diff --git a/static/locales/pl.json b/static/locales/pl.json index 724125f4..da2a023e 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -723,7 +723,9 @@ "modifiedAt": "Data modyfikacji", "createdAt": "Data utworzenia", "size": "Rozmiar", - "favoriteDate": "Data dodania do ulubionych" + "favoriteDate": "Data dodania do ulubionych", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Dzisiaj", diff --git a/static/locales/pt.json b/static/locales/pt.json index 63cada28..9835d164 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -723,7 +723,9 @@ "modifiedAt": "Data de modificação", "createdAt": "Data de criação", "size": "Tamanho", - "favoriteDate": "Data de favorito" + "favoriteDate": "Data de favorito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Hoje", diff --git a/static/locales/ru.json b/static/locales/ru.json index 1232cdf4..f470d2cc 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -723,7 +723,9 @@ "modifiedAt": "Дата изменения", "createdAt": "Дата создания", "size": "Размер", - "favoriteDate": "Дата добавления в избранное" + "favoriteDate": "Дата добавления в избранное", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Сегодня", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 7e053115..d6bd270e 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -723,7 +723,9 @@ "modifiedAt": "修改日期", "createdAt": "建立日期", "size": "大小", - "favoriteDate": "收藏日期" + "favoriteDate": "收藏日期", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今天", diff --git a/static/locales/zh.json b/static/locales/zh.json index 81a0ab32..6f04a4a2 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -723,7 +723,9 @@ "modifiedAt": "修改日期", "createdAt": "创建日期", "size": "大小", - "favoriteDate": "收藏日期" + "favoriteDate": "收藏日期", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今天", diff --git a/static/sw.js b/static/sw.js index 6bb430c5..ef92a431 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v21'; +const CACHE_NAME = 'oxicloud-cache-v22'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest diff --git a/tests/api/favorites.hurl b/tests/api/favorites.hurl index 83976e4f..f766d997 100644 --- a/tests/api/favorites.hurl +++ b/tests/api/favorites.hurl @@ -30,13 +30,13 @@ jsonpath "$.access_token" isString # ───────────────────────────────────────────────────────────── # Step 2 – No favorites yet # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" isCollection -jsonpath "$" count == 0 +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0 # ───────────────────────────────────────────────────────────── @@ -85,15 +85,15 @@ HTTP 201 # ───────────────────────────────────────────────────────────── # Step 5 – Favorites contains only hello-renamed.txt # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].item_id" == {{file_id}} -jsonpath "$[0].item_type" == "file" -jsonpath "$[0].item_name" == "hello-renamed.txt" +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource.id" == {{file_id}} +jsonpath "$.items[0].resource_type" == "file" +jsonpath "$.items[0].resource.name" == "hello-renamed.txt" # ───────────────────────────────────────────────────────────── @@ -108,14 +108,14 @@ HTTP 201 # ───────────────────────────────────────────────────────────── # Step 7 – Favorites contains both items (order-independent) # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 2 -jsonpath "$[*].item_id" contains {{file_id}} -jsonpath "$[*].item_id" contains {{test1_id}} +jsonpath "$.items" count == 2 +jsonpath "$.items[*].resource.id" contains {{file_id}} +jsonpath "$.items[*].resource.id" contains {{test1_id}} # ───────────────────────────────────────────────────────────── @@ -130,15 +130,15 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 9 – Favorites contains only test1 folder # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].item_id" == {{test1_id}} -jsonpath "$[0].item_type" == "folder" -jsonpath "$[0].item_name" == "test1" +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource.id" == {{test1_id}} +jsonpath "$.items[0].resource_type" == "folder" +jsonpath "$.items[0].resource.name" == "test1" # ───────────────────────────────────────────────────────────── @@ -153,9 +153,9 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 11 – Favorites is empty again # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/favorites +GET {{base_url}}/api/favorites/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 0 +jsonpath "$.items" count == 0 diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index a10ba6f1..264ae704 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -391,7 +391,7 @@ Authorization: Bearer {{adam_token}} HTTP 404 -GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated +GET {{base_url}}/api/folders/{{perm_folder_id}}/resources Authorization: Bearer {{adam_token}} HTTP 404 @@ -514,7 +514,7 @@ HTTP 200 jsonpath "$" count == 1 jsonpath "$[0].id" == "{{perm_child_id}}" -GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated +GET {{base_url}}/api/folders/{{perm_folder_id}}/resources Authorization: Bearer {{adam_token}} HTTP 200 diff --git a/tests/api/recent.hurl b/tests/api/recent.hurl index 2fd5c08b..c745adb4 100644 --- a/tests/api/recent.hurl +++ b/tests/api/recent.hurl @@ -71,14 +71,14 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 4 – Recent list contains hello-renamed.txt # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/recent +GET {{base_url}}/api/recent/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].item_type" == "file" -jsonpath "$[0].item_name" == "hello-renamed.txt" +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource_type" == "file" +jsonpath "$.items[0].resource.name" == "hello-renamed.txt" # ───────────────────────────────────────────────────────────── @@ -93,10 +93,10 @@ HTTP 200 # ───────────────────────────────────────────────────────────── # Step 6 – Recent list is empty after clear # ───────────────────────────────────────────────────────────── -GET {{base_url}}/api/recent +GET {{base_url}}/api/recent/resources Authorization: Bearer {{token}} HTTP 200 [Asserts] -jsonpath "$" isCollection -jsonpath "$" count == 0 +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0