Files
Oxicloud/static/js/features/sharing/fileSharing.js
T
DioCrafts 81a93a489b feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists.

Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets.

Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:24:27 +02:00

223 lines
7.2 KiB
JavaScript

/**
* OxiCloud - File Sharing Module
* All operations go through the backend API at /api/shares.
* No localStorage is used for share data.
*/
import { switchToSharedSection } from '../../app/navigation.js';
import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateTime } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
/**
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
*/
const fileSharing = {
/** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = true) {
const h = { ...getCsrfHeaders() };
if (json) h['Content-Type'] = 'application/json';
return h;
},
/**
* Create a shared link via backend API
* @param {string} itemId - ID of the file or folder
* @param {string} itemType - 'file' or 'folder'
* @param {CreateShare} [options] -
* @returns {Promise<Object>} ShareDto from backend
*/
// FIXME unused ?? duplicate with createSharedLink() from contextMenu
async createSharedLink(itemId, itemType, options) {
const body = {
item_id: itemId,
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
};
const res = await fetch('/api/shares', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Server error ${res.status}`);
}
return await res.json();
},
/**
* Get all shared links for the current user
* @returns {Promise<ShareItem[]>} Array of ShareDto
*/
async getSharedLinks() {
try {
const res = await fetch('/api/shares?page=1&per_page=1000', {
headers: this._headers(false)
});
if (!res.ok) return [];
const data = /** @type {ShareItem[]} */ await res.json();
return data.items || [];
} catch (error) {
console.error('Error fetching shared links:', error);
return [];
}
},
/**
* Get shared links for a specific item (server-side filtered)
* @param {string} itemId
* @param {string} itemType - 'file' or 'folder'
* @returns {Promise<ShareItem[]>} Shares for this item
*/
async getSharedLinksForItem(itemId, itemType) {
try {
const params = new URLSearchParams({
item_id: itemId,
item_type: itemType
});
const res = await fetch(`/api/shares?${params}`, {
headers: this._headers(false)
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : data.items || [];
} catch (error) {
console.error('Error getting shared links for item:', error);
return [];
}
},
/**
* Check if an item has any shared links
* @param {string} itemId
* @param {string} itemType
* @returns {Promise<boolean>}
*/
async hasSharedLinks(itemId, itemType) {
const links = await this.getSharedLinksForItem(itemId, itemType);
return links.length > 0;
},
/**
* Update a shared link
* @param {string} shareId
* @param {UpdateShare} updateData - { password, expires_at }
* @returns {Promise<Object>} Updated ShareDto
*/
async updateSharedLink(shareId, updateData) {
const body = {};
if (updateData.password !== undefined) body.password = updateData.password;
if (updateData.expires_at !== undefined) body.expires_at = updateData.expires_at;
const res = await fetch(`/api/shares/${shareId}`, {
method: 'PUT',
headers: this._headers(),
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Server error ${res.status}`);
}
return await res.json();
},
/**
* Delete a shared link
* @param {string} shareId
* @returns {Promise<boolean>}
*/
async removeSharedLink(shareId) {
try {
const res = await fetch(`/api/shares/${shareId}`, {
method: 'DELETE',
headers: this._headers(false)
});
return res.ok || res.status === 204;
} catch (error) {
console.error('Error removing shared link:', error);
return false;
}
},
/**
* 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<import('../../core/types.js').ShareItem>}
*/
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
*/
async copyLinkToClipboard(url) {
try {
await navigator.clipboard.writeText(url);
ui.showNotification(i18n.t('notif.linkCopied'), i18n.t('notif.linkCopiedBody'));
return true;
} catch (error) {
console.error('Error copying to clipboard:', error);
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.linkCopyError'));
return false;
}
},
/**
* Format expiration date for display (Unix timestamp in seconds or ISO string)
* @param {number|Date} value
* @returns {string}
*/
formatExpirationDate(value) {
if (!value) return 'No expiration';
return formatDateTime(value);
},
/**
* Send a notification about a shared resource (stub — no backend endpoint yet)
* @param {string} shareUrl
* @param {string} recipientEmail
* @param {string} _message
* @returns {Promise<boolean>}
*/
async sendShareNotification(shareUrl, recipientEmail, _message = '') {
// TODO: implement backend endpoint for email notifications
console.log(`Share notification for ${shareUrl} sent to ${recipientEmail}`);
ui.showNotification(i18n.t('notif.notificationSent'), i18n.t('notif.notificationSentBody', { email: recipientEmail }));
return true;
},
/**
* Initialize file sharing event listeners
*/
init() {
console.log('File sharing module initialized (API-backed)');
document.querySelectorAll('.nav-item').forEach((item) => {
const span = item.querySelector('span');
if (span && span.getAttribute('data-i18n') === 'nav.shared') {
item.addEventListener('click', () => {
switchToSharedSection();
});
}
});
}
};
export { fileSharing };