feat(myshares): new version of myshares sections, supporting grants Users + Tokens
This commit is contained in:
@@ -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 };
|
||||
@@ -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<string,string>} */
|
||||
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 = `<i class="fas fa-pencil-alt"></i> ${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 = '<i class="fas fa-ellipsis-v"></i>';
|
||||
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 = `<i class="${iconClass}"></i> `;
|
||||
}
|
||||
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 };
|
||||
@@ -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 <i>)
|
||||
* • Files: `.file-icon.{specialClass}` + optional thumbnail <img> + <i>
|
||||
*
|
||||
* 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 };
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -444,9 +444,7 @@ export class ResourceListComponent {
|
||||
el.innerHTML = `
|
||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||
<div class="name-cell">
|
||||
<div class="file-icon folder-icon">
|
||||
<i class="fas fa-folder"></i>
|
||||
</div>
|
||||
<div class="resource-icon-slot"></div>
|
||||
<span>${escapeHtml(folder.name)}</span>
|
||||
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||
@@ -461,6 +459,7 @@ export class ResourceListComponent {
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
|
||||
this._bindItemEvents(el, folder);
|
||||
return el;
|
||||
}
|
||||
@@ -472,8 +471,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 +478,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 +492,7 @@ export class ResourceListComponent {
|
||||
el.innerHTML = `
|
||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||
<div class="name-cell">
|
||||
<div class="file-icon ${iconSpecialClass}">
|
||||
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
||||
<i class="${iconClass}"></i>
|
||||
</div>
|
||||
<div class="resource-icon-slot"></div>
|
||||
<span>${escapeHtml(file.name)}</span>
|
||||
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||
@@ -514,18 +507,7 @@ export class ResourceListComponent {
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,23 +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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string for display ("Dec 31, 2026").
|
||||
* @param {string} dateStr
|
||||
* @returns {string}
|
||||
*/
|
||||
function _formatExpiryDate(dateStr) {
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||
const ROLE_PERMISSIONS = {
|
||||
viewer: ['read'],
|
||||
@@ -274,9 +264,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;
|
||||
@@ -505,9 +495,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';
|
||||
@@ -538,9 +528,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;
|
||||
@@ -587,78 +577,12 @@ const shareModal = {
|
||||
// ── Expiry chip toggle ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a compact expiry chip that toggles to an inline date input on click.
|
||||
*
|
||||
* Chip states:
|
||||
* • "∞ No expiry" — dashed border, faint text (value is null)
|
||||
* • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set)
|
||||
*
|
||||
* @param {string|null} initialValue - YYYY-MM-DD or null
|
||||
* @param {(v: string|null) => void} onChange - called whenever the value changes
|
||||
* @param {(v: string|null) => void} onChange
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_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 =
|
||||
`<i class="fas fa-clock"></i> ${_formatExpiryDate(current)}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
current = null;
|
||||
onChange(null);
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-infinity"></i> ${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;
|
||||
return buildExpiryChip(initialValue, onChange);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -667,72 +591,7 @@ const shareModal = {
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_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 =
|
||||
`<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hasPassword = false;
|
||||
onChange('');
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-lock-open"></i> ${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;
|
||||
return buildPasswordChip(initialHasPassword, onChange);
|
||||
},
|
||||
|
||||
// ── Links section ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user