style(front/js): apply types on all objects

- reduce amount of warnings in IDE
    - maximize API type mapping with static/js/core/types.js
This commit is contained in:
Edouard Vanbelle
2026-05-07 23:40:02 +02:00
parent a38475bd2c
commit fac184ccfe
43 changed files with 1614 additions and 574 deletions
+6 -3
View File
@@ -5,10 +5,13 @@
"allowJs": true, "allowJs": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"noImplicitAny": false, "noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"strictFunctionTypes": true,
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
// Treat all JS files as modules // Treat all JS files as modules
"moduleDetection": "force", "moduleDetection": "force",
@@ -17,6 +20,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"target": "ESNext" "target": "ESNext"
}, },
"include": ["static/js/**/*.js"], "include": ["static/js/**/*.js" ],
"exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs"] "exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs", "static/js/vendors/**/*.js" ]
} }
+1
View File
@@ -59,6 +59,7 @@ front-fmt:
front-lint: front-lint:
biome lint static/ biome lint static/
tsc -p jsconfig.json
# check CSS rules # check CSS rules
front-rules: front-rules:
+10
View File
@@ -8,6 +8,14 @@ import { updateStorageUsageDisplay } from './main.js';
import { app } from './state.js'; import { app } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
/**
* @import {User} from '../core/types.js'
*/
/**
*
* @returns {Promise<User | null>}
*/
async function refreshUserData() { async function refreshUserData() {
const USER_DATA_KEY = 'oxicloud_user'; const USER_DATA_KEY = 'oxicloud_user';
@@ -25,6 +33,7 @@ async function refreshUserData() {
return null; return null;
} }
/** @type {User} */
const userData = await response.json(); const userData = await response.json();
console.log('Refreshed user data from server:', userData); console.log('Refreshed user data from server:', userData);
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes); console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
@@ -83,6 +92,7 @@ async function checkAuthentication() {
// Check session validity by calling /api/auth/me (cookie auto-sent) // Check session validity by calling /api/auth/me (cookie auto-sent)
console.log('Checking session via /api/auth/me...'); console.log('Checking session via /api/auth/me...');
/** @type {User} */
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) { if (userData.username) {
// We have cached user data — render immediately, refresh in background // We have cached user data — render immediately, refresh in background
+10 -6
View File
@@ -9,14 +9,14 @@ import { app } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
import { uiNotifications } from './uiNotifications.js'; import { uiNotifications } from './uiNotifications.js';
/** @import {FileInfo, FolderInfo} from '../core/types.js' */ /** @import {FileItem, FolderItem} from '../core/types.js' */
let isLoadingFiles = false; let isLoadingFiles = false;
/** /**
* getFolder information * getFolder information
* @param {string} id the id of the folder * @param {string} id the id of the folder
* @returns {Promise<FolderInfo>} * @returns {Promise<FolderItem>}
*/ */
async function getFolder(id) { async function getFolder(id) {
/** @type {HeadersInit} */ /** @type {HeadersInit} */
@@ -47,7 +47,7 @@ async function getFolder(id) {
async function rebuildBreadCrumb() { async function rebuildBreadCrumb() {
/** /**
* Store the leaf (this is the current displayed folder) * Store the leaf (this is the current displayed folder)
* @type {FolderInfo | null} * @type {FolderItem | null}
*/ */
let currentFolderInfo = null; let currentFolderInfo = null;
@@ -172,7 +172,11 @@ async function loadFiles(options = { insertHistory: true }) {
if (forceRefresh) { if (forceRefresh) {
url += `&force_refresh=true`; url += `&force_refresh=true`;
if (requestOptions.headers) requestOptions.headers['X-Force-Refresh'] = 'true'; if (requestOptions.headers) {
const headers = new Headers(requestOptions.headers);
headers.set('X-Force-Refresh', 'true');
requestOptions.headers = headers;
}
console.log('Forcing complete refresh ignoring cache'); console.log('Forcing complete refresh ignoring cache');
} }
@@ -202,10 +206,10 @@ async function loadFiles(options = { insertHistory: true }) {
multiSelect.init(); // this will wire buttons & select-all-checkbox multiSelect.init(); // this will wire buttons & select-all-checkbox
} }
/** @type {FolderInfo[]} */ /** @type {FolderItem[]} */
const folderList = Array.isArray(listing.folders) ? listing.folders : []; const folderList = Array.isArray(listing.folders) ? listing.folders : [];
/** @type {FileInfo[]} */ /** @type {FileItem[]} */
const fileList = Array.isArray(listing.files) ? listing.files : []; const fileList = Array.isArray(listing.files) ? listing.files : [];
if (folderList.length === 0 && fileList.length === 0) { if (folderList.length === 0 && fileList.length === 0) {
+7 -2
View File
@@ -35,6 +35,10 @@ import { loadTrashItems } from './trashView.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
import { setupUserMenu } from './userMenu.js'; import { setupUserMenu } from './userMenu.js';
/**
* @import {User} from '../core/types.js'
*/
// Upload dropdown listener state (prevents accumulated listeners) // Upload dropdown listener state (prevents accumulated listeners)
/** @type {((e: MouseEvent) => void) | null} */ /** @type {((e: MouseEvent) => void) | null} */
let uploadDropdownDocumentClickHandler = null; let uploadDropdownDocumentClickHandler = null;
@@ -141,7 +145,7 @@ const ACTIONS_BAR_TEMPLATES = {
/** /**
* *
* @param {string} mode * @param {'files' | 'trash' | 'favorites' | 'recent' | 'hidden'} mode
* @param {boolean} [force=false] * @param {boolean} [force=false]
* @returns * @returns
*/ */
@@ -494,6 +498,7 @@ function setupEventListeners() {
ui.setupDragAndDrop(); ui.setupDragAndDrop();
// Debounce timer for live search // Debounce timer for live search
/** @type {ReturnType<typeof setTimeout>} */
let searchDebounceTimer = null; let searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300; const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 3; const SEARCH_MIN_CHARS = 3;
@@ -726,7 +731,7 @@ export function selectFolder(id, name) {
/** /**
* Update the storage usage display with the user's actual storage usage * Update the storage usage display with the user's actual storage usage
* @param {Object} userData - The user data object * @param {User} userData - The user data object
*/ */
function updateStorageUsageDisplay(userData) { function updateStorageUsageDisplay(userData) {
// Default values // Default values
-6
View File
@@ -136,12 +136,6 @@ export const SECTIONS_MAPPER = {
*/ */
function setCurrentSection(section) { function setCurrentSection(section) {
if (app.currentSection === section) return false; if (app.currentSection === section) return false;
// Set all view flags - true for active section, false for others
Object.entries(SECTIONS_MAPPER).forEach(([key, flag]) => {
app[flag] = key === section;
});
app.currentSection = section; app.currentSection = section;
// Update nav item active classes by finding matching item from DOM // Update nav item active classes by finding matching item from DOM
+11 -3
View File
@@ -8,9 +8,14 @@ import { app } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
/** /**
* @param {string} query * @import {SearchCriteria, SortByEnnum} from '../core/types.js'
* @param {string} [sortBy]
*/ */
/**
* @param {string} query
* @param {SortByEnnum} [sortBy]
*/
// FIXME: refactor with search.js ?
async function performSearch(query, sortBy) { async function performSearch(query, sortBy) {
console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`); console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`);
@@ -20,9 +25,11 @@ async function performSearch(query, sortBy) {
ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`); ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`);
/** @type {SearchCriteria} */
const options = { const options = {
recursive: true, recursive: true,
limit: 100, limit: 100,
offset: 0,
sort_by: sortBy || 'relevance' sort_by: sortBy || 'relevance'
}; };
@@ -51,7 +58,8 @@ document.addEventListener('search-resort', (e) => {
const event = /** @type {CustomEvent<{sort_by: string}>} */ (e); const event = /** @type {CustomEvent<{sort_by: string}>} */ (e);
const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input')); const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
if (searchInput?.value.trim()) { if (searchInput?.value.trim()) {
performSearch(searchInput.value.trim(), event.detail.sort_by); const sortBy = /** @type {SortByEnnum} */ (event.detail.sort_by);
performSearch(searchInput.value.trim(), sortBy);
} }
}); });
+37 -6
View File
@@ -3,39 +3,70 @@
* Centralized mutable state for app and cached DOM references. * Centralized mutable state for app and cached DOM references.
*/ */
/** @import {FolderInfo} from '../core/types.js' */ /** @import {FileItem, FolderItem, LightItem} from '../core/types.js' */
export const app = { export const app = {
currentView: 'grid', currentView: 'grid',
/** @type {string | null} */ /** @type {string | null} */
currentPath: '', currentPath: '',
/** @type {string | null} */
currentFolder: null, currentFolder: null,
/** @type {FolderInfo | null} */ /** @type {FolderItem | null} */
currentFolderInfo: null, currentFolderInfo: null,
/** @type {Object | null} */ /** @type {FolderItem | null} */
contextMenuTargetFolder: null, contextMenuTargetFolder: null,
/** @type {Object | null} */ /** @type {FileItem | null} */
contextMenuTargetFile: null, contextMenuTargetFile: null,
selectedTargetFolderId: '', selectedTargetFolderId: '',
moveDialogMode: 'file', moveDialogMode: 'file',
/** @type {string | null} */
moveDialogItemId: null,
/** @type {'file' | 'folder' | null} */
moveDialogItemMode: null,
/** @type {string | null} */
moveDialogCurrentFolderId: null,
/** @type {Array<{id: string, name: string}>} */
moveDialogBreadcrumb: [],
/** @type {FileItem[] | null} */
playlistDialogFiles: null,
/** @type {String | null} */ /** @type {String | null} */
currentSection: null, // will be defined on first call currentSection: null, // will be defined on first call
isSearchMode: false, isSearchMode: false,
/** @type {FileItem | FolderItem | null} */
shareDialogItem: null, shareDialogItem: null,
/** @type {'file' | 'folder' | null} */
shareDialogItemType: null, shareDialogItemType: null,
/** @type {String | null} */
notificationShareUrl: null, notificationShareUrl: null,
/** @type {string | null} */
userHomeFolderId: null, userHomeFolderId: null,
/** @type {string | null} */
userHomeFolderName: null, userHomeFolderName: null,
/** @type {Object[]} */
/** @type {Array<{id: string, name: string}>} */
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
/** @type {String | null} */ /** @type {String | null} */
viewFile: null // current file in inline view viewFile: null, // current file in inline view
/** @type {LightItem[] | null} */
batchMoveItems: null
}; };
export const appElements = { export const appElements = {
+10 -1
View File
@@ -9,6 +9,11 @@ import { multiSelect } from '../features/files/multiSelect.js';
import { appElements } from './state.js'; import { appElements } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
/**
*
* @import {TrashItem} from '../core/types.js'
*/
async function loadTrashItems() { async function loadTrashItems() {
const elements = appElements; const elements = appElements;
@@ -25,7 +30,7 @@ async function loadTrashItems() {
</div> </div>
`; `;
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
const trashItems = await fileOps.getTrashItems(); const trashItems = await fileOps.getTrashItems();
@@ -46,6 +51,10 @@ async function loadTrashItems() {
} }
} }
/**
*
* @param {TrashItem} item
*/
function addTrashItemToView(item) { function addTrashItemToView(item) {
const elements = appElements; const elements = appElements;
const isFile = item.item_type === 'file'; const isFile = item.item_type === 'file';
+100 -51
View File
@@ -25,10 +25,17 @@ import { app } from './state.js';
import { uiFileTypes } from './uiFileTypes.js'; import { uiFileTypes } from './uiFileTypes.js';
import { uiNotifications } from './uiNotifications.js'; import { uiNotifications } from './uiNotifications.js';
/**
* @import {FileItem, FolderItem} from '../core/types.js'
* @import {BatchResult} from '../features/files/fileOperations.js'
*/
// UI Module // UI Module
const ui = { const ui = {
/** @type {HTMLDListElement | null} */ /** @type {HTMLDivElement | null} */
//dragPreview, dragPreview: null,
/** @type {HTMLDivElement | null} */
draggedItems: null,
/** /**
* Initialize context menus and dialogs * Initialize context menus and dialogs
@@ -338,21 +345,31 @@ const ui = {
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
/**
*
* @param {DataTransfer} dataTransfer
* @returns {Promise<any[]|null>}
*/
const collectDroppedEntries = async (dataTransfer) => { const collectDroppedEntries = async (dataTransfer) => {
const items = Array.from(dataTransfer?.items || []); const items = Array.from(dataTransfer?.items || []);
const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean); const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean);
if (rootEntries.length === 0) return null; if (rootEntries.length === 0) return null;
/** @type {Array<{file: File, relativePath: string}>} */
const out = []; const out = [];
/**
* @param {FileSystemEntry} entry
* @param {string} prefix
*/
const walkEntry = async (entry, prefix = '') => { const walkEntry = async (entry, prefix = '') => {
if (!entry) return; if (!entry) return;
if (entry.isFile) { if (entry.isFile) {
await new Promise((resolve) => { await new Promise((resolve) => {
entry.file( /** @type {FileSystemFileEntry} */ (entry).file(
(file) => { (/** @type {File} */ file) => {
out.push({ file, relativePath: `${prefix}${file.name}` }); out.push({ file, relativePath: `${prefix}${file.name}` });
resolve(undefined); resolve(undefined);
}, },
@@ -364,7 +381,7 @@ const ui = {
if (entry.isDirectory) { if (entry.isDirectory) {
const dirPrefix = `${prefix}${entry.name}/`; const dirPrefix = `${prefix}${entry.name}/`;
const reader = entry.createReader(); const reader = /** @type {FileSystemDirectoryEntry} */ (entry).createReader();
while (true) { while (true) {
const children = await new Promise((resolve) => { const children = await new Promise((resolve) => {
@@ -636,7 +653,7 @@ const ui = {
/** /**
* Check if a file can be previewed in the viewer * Check if a file can be previewed in the viewer
* @param {Object} file - File object with mime_type property * @param {FileItem} file
* @returns {boolean} * @returns {boolean}
*/ */
isViewableFile(file) { isViewableFile(file) {
@@ -647,6 +664,7 @@ const ui = {
* Get FontAwesome icon class for a filename based on its extension. * Get FontAwesome icon class for a filename based on its extension.
* Used as fallback when the backend DTO doesn't include icon_class * Used as fallback when the backend DTO doesn't include icon_class
* (e.g. trash items). * (e.g. trash items).
* @param {string} fileName
*/ */
getIconClass(fileName) { getIconClass(fileName) {
return uiFileTypes.getIconClass(fileName); return uiFileTypes.getIconClass(fileName);
@@ -655,6 +673,7 @@ const ui = {
/** /**
* Get CSS special class for icon styling based on filename extension. * Get CSS special class for icon styling based on filename extension.
* Used as fallback when the backend DTO doesn't include icon_special_class. * Used as fallback when the backend DTO doesn't include icon_special_class.
* @param {string} fileName
*/ */
getIconSpecialClass(fileName) { getIconSpecialClass(fileName) {
return uiFileTypes.getIconSpecialClass(fileName); return uiFileTypes.getIconSpecialClass(fileName);
@@ -695,13 +714,13 @@ const ui = {
* Data store + event delegation (replaces per-item listeners) * Data store + event delegation (replaces per-item listeners)
* ================================================================ */ * ================================================================ */
/** @type {Map<string, Object>} item data keyed by id */ /** @type {Map<string, FolderItem | FileItem>} item data keyed by id */
_items: new Map(), _items: new Map(),
/** @type {Array<Object>} last rendered folder dataset */ /** @type {FolderItem[]} last rendered folder dataset */
_lastFolders: [], _lastFolders: [],
/** @type {Array<Object>} last rendered file dataset */ /** @type {FileItem[]} last rendered file dataset */
_lastFiles: [], _lastFiles: [],
/** @type {boolean} */ /** @type {boolean} */
@@ -716,9 +735,7 @@ const ui = {
}, },
/** /**
* * @param {FolderItem[]} folders
* @param {Object[]} folders
* @returns
*/ */
_renderFoldersToView(folders) { _renderFoldersToView(folders) {
if (!Array.isArray(folders) || folders.length === 0) return; if (!Array.isArray(folders) || folders.length === 0) return;
@@ -733,9 +750,7 @@ const ui = {
}, },
/** /**
* * @param {FileItem[]} files
* @param {Object[]} files
* @returns
*/ */
_renderFilesToView(files) { _renderFilesToView(files) {
if (!Array.isArray(files) || files.length === 0) return; if (!Array.isArray(files) || files.length === 0) return;
@@ -749,6 +764,10 @@ const ui = {
target.appendChild(frag); target.appendChild(frag);
}, },
/**
* @param {any[]} arr
* @param {any} item
*/
_upsertById(arr, item) { _upsertById(arr, item) {
if (!Array.isArray(arr) || !item?.id) return; if (!Array.isArray(arr) || !item?.id) return;
const idx = arr.findIndex((x) => x && x.id === item.id); const idx = arr.findIndex((x) => x && x.id === item.id);
@@ -796,6 +815,7 @@ const ui = {
await fileOps.moveFile(sourceId, targetFolderId); await fileOps.moveFile(sourceId, targetFolderId);
*/ */
/** @type {BatchResult} */
let result; let result;
switch (action) { switch (action) {
case 'copy': case 'copy':
@@ -843,6 +863,7 @@ const ui = {
this._delegationReady = true; this._delegationReady = true;
// ── helpers ──────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────
/** @param {HTMLDivElement} card */
const itemInfo = (card) => { const itemInfo = (card) => {
if (!card) return null; if (!card) return null;
const fileId = card.dataset.fileId; const fileId = card.dataset.fileId;
@@ -864,6 +885,7 @@ const ui = {
return null; return null;
}; };
/** @param {FileItem} file */
const openFile = async (file) => { const openFile = async (file) => {
if (!file) return; if (!file) return;
if (recent) { if (recent) {
@@ -897,6 +919,7 @@ const ui = {
} }
}; };
/** @param {HTMLElement} card */
const navigateFolder = (card) => { const navigateFolder = (card) => {
const folderId = card.dataset.folderId; const folderId = card.dataset.folderId;
const folderName = card.dataset.folderName; const folderName = card.dataset.folderName;
@@ -912,27 +935,31 @@ const ui = {
loadFiles(); loadFiles();
}; };
/**
* @param {HTMLElement} card
* @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info
*/
const setContextTarget = (card, info) => { const setContextTarget = (card, info) => {
if (info.type === 'folder') { if (info.type === 'folder') {
app.contextMenuTargetFolder = { app.contextMenuTargetFolder = /** @type {FolderItem} */ ({
id: info.id, id: info.id,
name: card.dataset.folderName, name: card.dataset.folderName,
parent_id: card.dataset.parentId || '' parent_id: card.dataset.parentId || ''
}; });
} else { } else {
const fileData = info.data || this._items.get(info.id); const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id));
app.contextMenuTargetFile = { app.contextMenuTargetFile = /** @type {FileItem} */ ({
id: info.id, id: info.id,
name: card.dataset.fileName, name: card.dataset.fileName,
folder_id: card.dataset.folderId || '', folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null mime_type: fileData?.mime_type || null
}; });
} }
}; };
// ── click (open / navigate; select only via checkbox) ── // ── click (open / navigate; select only via checkbox) ──
filesList.addEventListener('click', (e) => { filesList.addEventListener('click', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return; if (!card) return;
if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) { if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
@@ -975,7 +1002,7 @@ const ui = {
if (info.type === 'folder') { if (info.type === 'folder') {
navigateFolder(card); navigateFolder(card);
} else { } else {
openFile(info.data); openFile(/** @type {FileItem} */ (info.data));
} }
}); });
@@ -989,7 +1016,7 @@ const ui = {
// ── shared events ────────────────────── // ── shared events ──────────────────────
filesList.addEventListener('contextmenu', (e) => { filesList.addEventListener('contextmenu', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return; if (!card) return;
e.preventDefault(); e.preventDefault();
const info = itemInfo(card); const info = itemInfo(card);
@@ -1008,7 +1035,7 @@ const ui = {
// dragstart // dragstart
filesList.addEventListener('dragstart', (e) => { filesList.addEventListener('dragstart', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) { if (!card) {
e.preventDefault(); e.preventDefault();
return; return;
@@ -1088,9 +1115,11 @@ const ui = {
// TODO better naming like ("selection in ${parent.name}") modulo i18n ? ... // TODO better naming like ("selection in ${parent.name}") modulo i18n ? ...
const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-'); const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-');
nameEncoded = `oxicloud ${now}.zip`; nameEncoded = `oxicloud ${now}.zip`;
/** @type {string[]} */
const folders = []; const folders = [];
/** @type {string[]} */
const files = []; const files = [];
filesList.querySelectorAll(`div.selected`).forEach((e) => { /** @type {NodeListOf<HTMLDivElement>} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => {
const item = itemInfo(e); const item = itemInfo(e);
if (item.type === 'file') { if (item.type === 'file') {
files.push(item.id); files.push(item.id);
@@ -1164,6 +1193,7 @@ const ui = {
* Favorite star helper – attaches a direct click handler to a * Favorite star helper – attaches a direct click handler to a
* star <button> so the event never bubbles to the card. * star <button> so the event never bubbles to the card.
* ================================================================ */ * ================================================================ */
/** @param {HTMLElement} el */
_bindStarClick(el) { _bindStarClick(el) {
const star = el.querySelector('.favorite-star'); const star = el.querySelector('.favorite-star');
star?.addEventListener('click', (e) => { star?.addEventListener('click', (e) => {
@@ -1174,7 +1204,8 @@ const ui = {
if (!favorites) return; if (!favorites) return;
// FIXME: make a function // FIXME: make a function
const itemElement = shared?.closest('.file-item'); const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId; const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder'; const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
@@ -1187,7 +1218,7 @@ const ui = {
favorites.removeFromFavorites(itemId, itemType); favorites.removeFromFavorites(itemId, itemType);
} else { } else {
this.setFavoriteVisualState(itemId, itemType, true); this.setFavoriteVisualState(itemId, itemType, true);
favorites.addToFavorites(itemId, itemName, itemType); favorites.addToFavorites(itemId, itemName, itemType, null);
} }
// Keep context-menu label in sync if available // Keep context-menu label in sync if available
@@ -1201,26 +1232,30 @@ const ui = {
e.preventDefault(); e.preventDefault();
// FIXME: make a function // FIXME: make a function
const itemElement = shared?.closest('.file-item'); const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId; const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder'; const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName; const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
// TODO corrently dirty // TODO corrently dirty
const item = { const item = /** @type {unknown} */ ({
id: itemId, id: itemId,
item_id: itemId, item_id: itemId,
item_type: itemType, item_type: itemType,
item_name: itemName item_name: itemName
}; });
contextMenus.showShareDialog(item, itemType); contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
}); });
}, },
/** /**
* Sync favorite visuals for a file/folder across grid and list views. * Sync favorite visuals for a file/folder across grid and list views.
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isFavorite
*/ */
setFavoriteVisualState(itemId, itemType, isFavorite) { setFavoriteVisualState(itemId, itemType, isFavorite) {
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`; const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1258,6 +1293,11 @@ const ui = {
} }
}, },
/**
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isShared
*/
setSharedVisualState(itemId, itemType, isShared) { setSharedVisualState(itemId, itemType, isShared) {
console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`); console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`);
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`; const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1273,7 +1313,10 @@ const ui = {
* Element-creation helpers * Element-creation helpers
* ================================================================ */ * ================================================================ */
/** Create a list row for a folder */ /**
* Create a list row for a folder
* @param {FolderItem} folder
*/
_createFolderItem(folder) { _createFolderItem(folder) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'file-item'; el.className = 'file-item';
@@ -1314,7 +1357,10 @@ const ui = {
return el; return el;
}, },
/** Create a grid card for a file */ /**
* Create a grid card for a file
* @param {FileItem} file
*/
_createFileItem(file) { _createFileItem(file) {
const iconClass = file.icon_class || this.getIconClass(file.name); const iconClass = file.icon_class || this.getIconClass(file.name);
const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name); const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name);
@@ -1425,7 +1471,7 @@ const ui = {
* Render an array of folders into both grid and list views * Render an array of folders into both grid and list views
* using DocumentFragment for minimal reflows. * using DocumentFragment for minimal reflows.
* *
* @param {FolderInfo[]} folders * @param {FolderItem[]} folders
*/ */
renderFolders(folders) { renderFolders(folders) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1442,6 +1488,7 @@ const ui = {
/** /**
* Render an array of files into both grid and list views * Render an array of files into both grid and list views
* using DocumentFragment for minimal reflows. * using DocumentFragment for minimal reflows.
* @param {FileItem[]} files
*/ */
renderFiles(files) { renderFiles(files) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1461,7 +1508,7 @@ const ui = {
/** /**
* Add a single folder to the active view. * Add a single folder to the active view.
* @param {Object} folder - Folder object * @param {FolderItem} folder
*/ */
addFolderToView(folder) { addFolderToView(folder) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1479,7 +1526,7 @@ const ui = {
/** /**
* Add a single file to the active view. * Add a single file to the active view.
* @param {Object} file - File object * @param {FileItem} file
*/ */
addFileToView(file) { addFileToView(file) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1501,6 +1548,8 @@ const ui = {
/** /**
* Toggle selection state of a file/folder card. * Toggle selection state of a file/folder card.
* Routes through the multiSelect module so batch actions know about selected items. * Routes through the multiSelect module so batch actions know about selected items.
* @param {HTMLDivElement} card
* @param {MouseEvent} event
*/ */
function toggleCardSelection(card, event) { function toggleCardSelection(card, event) {
if (multiSelect) { if (multiSelect) {
@@ -1512,6 +1561,8 @@ function toggleCardSelection(card, event) {
/** /**
* Show the context menu anchored next to a trigger element (the 3-dot button). * Show the context menu anchored next to a trigger element (the 3-dot button).
* @param {HTMLElement} triggerElement
* @param {string} menuId
*/ */
function showContextMenuAtElement(triggerElement, menuId) { function showContextMenuAtElement(triggerElement, menuId) {
// Hide any open menus first // Hide any open menus first
@@ -1542,8 +1593,6 @@ function showContextMenuAtElement(triggerElement, menuId) {
menu.classList.remove('hidden'); menu.classList.remove('hidden');
} }
let __rubberBandJustFinished = false;
/** /**
* Rubber band (lasso) selection — click + drag on empty grid area * Rubber band (lasso) selection — click + drag on empty grid area
* to draw a rectangle and select all cards it touches. * to draw a rectangle and select all cards it touches.
@@ -1567,16 +1616,18 @@ function initRubberBandSelection() {
if (!container) return; if (!container) return;
container.addEventListener('mousedown', (e) => { container.addEventListener('mousedown', (e) => {
if (!(e instanceof MouseEvent)) return;
// Only start if clicking empty area (not on a card, button, menu, input…) // Only start if clicking empty area (not on a card, button, menu, input…)
if (e.button !== 0) return; // left click only if (e.button !== 0) return; // left click only
const target = /** @type {Element} */ (e.target);
if ( if (
e.target.closest('.file-item') || target.closest('.file-item') ||
e.target.closest('.context-menu') || target.closest('.context-menu') ||
e.target.closest('.upload-dropdown') || target.closest('.upload-dropdown') ||
e.target.closest('button') || target.closest('button') ||
e.target.closest('input') || target.closest('input') ||
e.target.closest('.breadcrumb') || target.closest('.breadcrumb') ||
e.target.closest('.list-header') target.closest('.list-header')
) )
return; return;
@@ -1627,14 +1678,14 @@ function initRubberBandSelection() {
// Sync with multiSelect module // Sync with multiSelect module
if (multiSelect) { if (multiSelect) {
const info = multiSelect._extractInfo(card); const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.select(info.id, info.name, info.type, info.parentId); if (info) multiSelect.select(info.id, info.name, info.type, info.parentId);
} }
} else { } else {
card.classList.remove('selected'); card.classList.remove('selected');
// Deselect from multiSelect module // Deselect from multiSelect module
if (multiSelect) { if (multiSelect) {
const info = multiSelect._extractInfo(card); const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.deselect(info.id); if (info) multiSelect.deselect(info.id);
} }
} }
@@ -1651,10 +1702,7 @@ function initRubberBandSelection() {
// Suppress the click event that follows mouseup so the global // Suppress the click event that follows mouseup so the global
// deselect handler doesn't immediately clear the selection. // deselect handler doesn't immediately clear the selection.
if (hadSelection) { if (hadSelection) {
__rubberBandJustFinished = true; requestAnimationFrame(() => {});
requestAnimationFrame(() => {
__rubberBandJustFinished = false;
});
} }
}); });
} }
@@ -1709,6 +1757,7 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t
overlay.classList.add('active'); overlay.classList.add('active');
}); });
/** @param {boolean} result */
const cleanup = (result) => { const cleanup = (result) => {
overlay.classList.remove('active'); overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200); setTimeout(() => overlay.remove(), 200);
+2 -2
View File
@@ -5,7 +5,7 @@
import { isTextViewable } from '../core/formatters.js'; import { isTextViewable } from '../core/formatters.js';
/** @import {FileInfo} from '../core/types.js' */ /** @import {FileItem} from '../core/types.js' */
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
const ICON_CLASS_MAP = { const ICON_CLASS_MAP = {
@@ -158,7 +158,7 @@ const uiFileTypes = {
// TODO: 'd better to use a canViw() method in inlineViewer // TODO: 'd better to use a canViw() method in inlineViewer
/** /**
* *
* @param {FileInfo} file * @param {FileItem} file
* @returns {boolean} * @returns {boolean}
*/ */
isViewableFile(file) { isViewableFile(file) {
+4
View File
@@ -18,6 +18,10 @@ function getCsrfToken() {
return match ? match.split('=')[1] : ''; return match ? match.split('=')[1] : '';
} }
/**
* returns headers to add, this includes the X-CSRF-Token
* @returns {Record<String, String>}
*/
function getCsrfHeaders() { function getCsrfHeaders() {
const token = getCsrfToken(); const token = getCsrfToken();
return token ? { 'X-CSRF-Token': token } : {}; return token ? { 'X-CSRF-Token': token } : {};
+1
View File
@@ -37,6 +37,7 @@ const WRAPPER_USER_DATA_KEY = 'oxicloud_user';
let _originalFetch = window.fetch.bind(window); let _originalFetch = window.fetch.bind(window);
/** Deduplicates concurrent refresh attempts into a single in-flight promise. */ /** Deduplicates concurrent refresh attempts into a single in-flight promise. */
/** @type {Promise<boolean> | null} */
let _refreshInFlight = null; let _refreshInFlight = null;
async function _refresh() { async function _refresh() {
+11 -1
View File
@@ -1,6 +1,7 @@
/** /**
* OxiCloud - Shared format and escaping utilities * OxiCloud - Shared format and escaping utilities
* Centralized global helpers for date/size/text formatting and XSS-safe escaping. * Centralized global helpers for date/size/text formatting and XSS-safe escaping.
* Contains also checkers
*/ */
/** /**
@@ -97,4 +98,13 @@ function isTextViewable(mimeType) {
return TEXT_TYPES.includes(mimeType); return TEXT_TYPES.includes(mimeType);
} }
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isTextViewable }; /**
* Chekif an email is valid
* @param {string} email
* @returns boolean
*/
function isEmailValid(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable };
+15 -7
View File
@@ -37,7 +37,7 @@ function resolveBrowserLocale() {
let currentLocale = resolveBrowserLocale(); let currentLocale = resolveBrowserLocale();
// Cache for translations // Cache for translations
/** @type {Record<string, Object>} */ /** @type {Record<string, any>} */
const translations = {}; const translations = {};
/** /**
@@ -71,7 +71,7 @@ async function loadTranslations(locale) {
/** /**
* Get a nested translation value * Get a nested translation value
* @param {object} obj - The translations object * @param {Record<string, any>} obj - The translations object
* @param {string} path - The dot-notation path to the translation * @param {string} path - The dot-notation path to the translation
* @returns {string|null} - The translation value or null if not found * @returns {string|null} - The translation value or null if not found
*/ */
@@ -110,7 +110,7 @@ function getNestedValue(obj, path) {
/** /**
* Replace parameters in a translation string * Replace parameters in a translation string
* @param {string} text - The translation string with placeholders * @param {string} text - The translation string with placeholders
* @param {object} params - The parameters to replace * @param {Record<string, any>} params - The parameters to replace
* @returns {string} - The interpolated string * @returns {string} - The interpolated string
*/ */
function interpolate(text, params) { function interpolate(text, params) {
@@ -244,11 +244,19 @@ document.addEventListener('DOMContentLoaded', async () => {
// Self-contained t wrapper — does NOT call the global t() because other // Self-contained t wrapper — does NOT call the global t() because other
// scripts (e.g. admin.js) may shadow it, which would cause infinite recursion. // scripts (e.g. admin.js) may shadow it, which would cause infinite recursion.
function safeT(key, params = {}) { /**
* @param {string} key
* @param {string | Record<string, any>} [paramsOrFallback] - interpolation params object, or a string fallback used when the key is missing
* @returns {string}
*/
function safeT(key, paramsOrFallback = {}) {
const fallback = typeof paramsOrFallback === 'string' ? paramsOrFallback : null;
const params = typeof paramsOrFallback === 'object' ? paramsOrFallback : {};
const localeData = translations[currentLocale]; const localeData = translations[currentLocale];
if (!localeData) { if (!localeData) {
// Translations not loaded yet — return humanised key suffix // Translations not loaded yet — return fallback or humanised key suffix
return key.split('.').pop() || key; return fallback ?? key.split('.').pop() ?? key;
} }
let value = getNestedValue(localeData, key); let value = getNestedValue(localeData, key);
@@ -258,7 +266,7 @@ function safeT(key, params = {}) {
value = getNestedValue(translations.en, key); value = getNestedValue(translations.en, key);
} }
if (!value) return key; if (!value) return fallback ?? key;
return interpolate(value, params); return interpolate(value, params);
} }
+2 -1
View File
@@ -15,6 +15,7 @@
// All icons use viewBox="0 0 {width} 512" and fill="currentColor". // All icons use viewBox="0 0 {width} 512" and fill="currentColor".
// Keys use FA5 class names (without "fa-" prefix) for backward compatibility. // Keys use FA5 class names (without "fa-" prefix) for backward compatibility.
/** @type {Record<String, Array<number | String>>} */
const OxiIcons = { const OxiIcons = {
'arrow-left': [ 'arrow-left': [
448, 448,
@@ -503,7 +504,7 @@ function replaceIconsInElement(container) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', 'currentColor'); path.setAttribute('fill', 'currentColor');
path.setAttribute('d', d); path.setAttribute('d', /** @type {string} */ (d));
svg.appendChild(path); svg.appendChild(path);
el.replaceWith(svg); el.replaceWith(svg);
+12 -1
View File
@@ -34,6 +34,10 @@ function getAvailableLanguages() {
const rtlLanguages = ['fa', 'ar']; const rtlLanguages = ['fa', 'ar'];
// Update HTML lang attribute and dir for RTL languages // Update HTML lang attribute and dir for RTL languages
/**
*
* @param {string} langCode
*/
function updateHtmlAttributes(langCode) { function updateHtmlAttributes(langCode) {
const htmlElement = document.documentElement; const htmlElement = document.documentElement;
@@ -150,6 +154,7 @@ function createLanguageSelector(containerId = 'language-selector') {
/** /**
* Toggle dropdown open/closed * Toggle dropdown open/closed
* @param {HTMLElement} container
*/ */
function toggleDropdown(container) { function toggleDropdown(container) {
const isOpen = container.classList.contains('open'); const isOpen = container.classList.contains('open');
@@ -162,6 +167,7 @@ function toggleDropdown(container) {
/** /**
* Open dropdown * Open dropdown
* @param {HTMLElement} container
*/ */
function openDropdown(container) { function openDropdown(container) {
container.classList.add('open'); container.classList.add('open');
@@ -173,6 +179,7 @@ function openDropdown(container) {
/** /**
* Close dropdown * Close dropdown
* @param {HTMLElement} container
*/ */
function closeDropdown(container) { function closeDropdown(container) {
container.classList.remove('open'); container.classList.remove('open');
@@ -184,6 +191,8 @@ function closeDropdown(container) {
/** /**
* Select a language * Select a language
* @param {String} langCode
* @param {HTMLElement} container
*/ */
async function selectLanguage(langCode, container) { async function selectLanguage(langCode, container) {
await i18n.setLocale(langCode); await i18n.setLocale(langCode);
@@ -197,6 +206,8 @@ async function selectLanguage(langCode, container) {
/** /**
* Update the UI to reflect selected language * Update the UI to reflect selected language
* @param {String} langCode
* @param {HTMLElement} container
*/ */
function updateSelectedLanguage(langCode, container) { function updateSelectedLanguage(langCode, container) {
const languages = getAvailableLanguages(); const languages = getAvailableLanguages();
@@ -213,7 +224,7 @@ function updateSelectedLanguage(langCode, container) {
options.forEach((option) => { options.forEach((option) => {
const isActive = option.getAttribute('data-lang') === langCode; const isActive = option.getAttribute('data-lang') === langCode;
option.classList.toggle('active', isActive); option.classList.toggle('active', isActive);
option.setAttribute('aria-selected', isActive); option.setAttribute('aria-selected', String(isActive));
}); });
} }
+18 -18
View File
@@ -10,25 +10,25 @@ const Modal = {
// Modal element references // Modal element references
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
overlay: null, overlay: null,
// FIXME: unused ?
container: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
icon: null, icon: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
title: null, title: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
label: null, label: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLInputElement | null} */
input: null, input: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLButtonElement | null} */
cancelBtn: null, cancelBtn: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLButtonElement | null} */
confirmBtn: null, confirmBtn: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLButtonElement | null} */
closeBtn: null, closeBtn: null,
// Current callback // Current callback
/** @private @type {Function | null} */
onConfirm: null, onConfirm: null,
/** @private @type {Function | null} */
onCancel: null, onCancel: null,
/** @private @type {((value: string) => Promise<void>) | null} */ /** @private @type {((value: string) => Promise<void>) | null} */
@@ -53,10 +53,10 @@ const Modal = {
this.icon = document.getElementById('modal-icon'); this.icon = document.getElementById('modal-icon');
this.title = document.getElementById('modal-title'); this.title = document.getElementById('modal-title');
this.label = document.getElementById('modal-label'); this.label = document.getElementById('modal-label');
this.input = document.getElementById('modal-input'); this.input = /** @type {HTMLInputElement} */ (document.getElementById('modal-input'));
this.cancelBtn = document.getElementById('modal-cancel-btn'); this.cancelBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-cancel-btn'));
this.confirmBtn = document.getElementById('modal-confirm-btn'); this.confirmBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-confirm-btn'));
this.closeBtn = document.getElementById('modal-close-btn'); this.closeBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-close-btn'));
// Event listeners // Event listeners
this.errorEl = document.getElementById('modal-error'); this.errorEl = document.getElementById('modal-error');
@@ -106,13 +106,13 @@ const Modal = {
/** /**
* Show input modal (replacement for prompt()) * Show input modal (replacement for prompt())
* @param {Object} options - Modal configuration * @param {Object} options - Modal configuration
* @param {string} options.title - Modal title * @param {string} [options.title] - Modal title
* @param {string} options.label - Input label * @param {string} [options.label] - Input label
* @param {string} options.placeholder - Input placeholder * @param {string} [options.placeholder] - Input placeholder
* @param {string} options.value - Initial input value * @param {string} [options.value] - Initial input value
* @param {string} options.icon - Font Awesome icon class (e.g., 'fa-folder-plus') * @param {string} [options.icon] - Font Awesome icon class (e.g., 'fa-folder-plus')
* @param {string} options.confirmText - Confirm button text * @param {string} [options.confirmText] - Confirm button text
* @param {string} options.cancelText - Cancel button text * @param {string} [options.cancelText] - Cancel button text
* @param {(value: string) => Promise<void>} [options.action] - Async action called on confirm. * @param {(value: string) => Promise<void>} [options.action] - Async action called on confirm.
* Throw an Error to keep the modal open and display the error message inline. * Throw an Error to keep the modal open and display the error message inline.
* When omitted the modal resolves immediately with the input value (legacy behaviour). * When omitted the modal resolves immediately with the input value (legacy behaviour).
@@ -292,7 +292,7 @@ const Modal = {
if (this.onConfirm) this.onConfirm(); if (this.onConfirm) this.onConfirm();
this.close(true); this.close(true);
} catch (e) { } catch (e) {
this.showError(e.message || 'An error occurred'); this.showError(/** @type {Error} */ (e).message || 'An error occurred');
this.confirmBtn.disabled = false; this.confirmBtn.disabled = false;
this.input.focus(); this.input.focus();
} }
+25 -1
View File
@@ -15,13 +15,28 @@ import { i18n } from './i18n.js';
* clear() * clear()
*/ */
/**
* @typedef {Object} BatchNotification
* @property {HTMLElement} el
* @property {Number} totalFiles,
* @property {Number} completed
* @property {Number} successCount
* @property {Number} errorCount
* @property {Number} lastLabelUpdateTs
* @property {String} lastLabelFile
*/
const notifications = (() => { const notifications = (() => {
/* ── state ──────────────────────────────────────────────── */ /* ── state ──────────────────────────────────────────────── */
let _badgeCount = 0; let _badgeCount = 0;
let _batchSeq = 0; let _batchSeq = 0;
const _batches = {}; // batchId → { el, files:{}, totalFiles }
/** @type {Record<String,BatchNotification>} */
const _batches = {};
/* ── DOM refs (resolved lazily) ─────────────────────────── */ /* ── DOM refs (resolved lazily) ─────────────────────────── */
/** @type {(id: string) => HTMLElement | null } */
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
/* ── bell toggle ────────────────────────────────────────── */ /* ── bell toggle ────────────────────────────────────────── */
@@ -237,6 +252,8 @@ const notifications = (() => {
/** /**
* Mark a file as completed within a batch (updates overall bar). * Mark a file as completed within a batch (updates overall bar).
* DOM updates are throttled to every 5 files to avoid reflow starvation. * DOM updates are throttled to every 5 files to avoid reflow starvation.
* @param {string} batchId
* @param {boolean} success
*/ */
function fileCompleted(batchId, success) { function fileCompleted(batchId, success) {
const batch = _batches[batchId]; const batch = _batches[batchId];
@@ -262,6 +279,9 @@ const notifications = (() => {
/** /**
* Finalise a batch – update icon and title. * Finalise a batch – update icon and title.
* @param {string} batchId
* @param {number} successCount
* @param {number} totalFiles
*/ */
function finishBatch(batchId, successCount, totalFiles) { function finishBatch(batchId, successCount, totalFiles) {
const batch = _batches[batchId]; const batch = _batches[batchId];
@@ -320,6 +340,10 @@ const notifications = (() => {
} }
/* ── util ───────────────────────────────────────────────── */ /* ── util ───────────────────────────────────────────────── */
// FIXME move to global library
/**
* @param {string} s
*/
function _esc(s) { function _esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s; d.textContent = s;
+186 -4
View File
@@ -1,5 +1,19 @@
/** /**
* @typedef {Object} FolderInfo * @typedef {'file' | 'folder'} ItemTypeEnum
*/
// FIXME to simplify
/**
* @typedef {Object} LightItem
* @property {string} id
* @property {string} name
* @property {ItemTypeEnum} type
* @property {string} parentId
*/
//FIXME: rename into FolderItem
/**
* @typedef {Object} FolderItem
* @property {string} category * @property {string} category
* @property {number} created_at - timestamp * @property {number} created_at - timestamp
* @property {string} icon_class * @property {string} icon_class
@@ -13,8 +27,9 @@
* @property {string} path the full path * @property {string} path the full path
*/ */
//FIXME: rename into FileItem
/** /**
* @typedef {Object} FileInfo * @typedef {Object} FileItem
* @property {string} category * @property {string} category
* @property {number} created_at - timestamp * @property {number} created_at - timestamp
* @property {string} icon_class * @property {string} icon_class
@@ -39,7 +54,7 @@
*/ */
/** /**
* @typedef {Object} Share * @typedef {Object} ShareItem
* @property {number} access_count * @property {number} access_count
* @property {number} created_at - timestamp * @property {number} created_at - timestamp
* @property {String} created_by * @property {String} created_by
@@ -48,8 +63,175 @@
* @property {string} id * @property {string} id
* @property {string} item_id * @property {string} item_id
* @property {string} item_name * @property {string} item_name
* @property {string} item_type * @property {ItemTypeEnum} item_type
* @property {SharePermissions} permissions * @property {SharePermissions} permissions
* @property {string | null} token * @property {string | null} token
* @property {string} url * @property {string} url
*/ */
/**
* @typedef {Object} CreateShare
* @property {string} item_id
* @property {string|null} [item_name]
* @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
*/
/**
* @typedef {Object} FavoriteItem
* @property {string} id
* @property {string} user_id
* @property {string} item_id /// ID of the favorited item (file or folder)
* @property {ItemTypeEnum} item_type
* @property {number} created_at
* @property {string|null} item_name: null if folder
* @property {number|null} item_size null if folder
* @property {string|null} item_mime_type if file
* @property {string|null} parent_id
* @property {number|null} modified_at: Option<DateTime<Utc>>,
* @property {String} item_path Full human-readable path (e.g. "Documents/Work" for a folder, "Documents/Work/report.pdf" for a file)
* @property {String} icon_class
* @property {String} icon_special_class
* @property {String} category
* @property {String} size_formatted
*/
/**
* @typedef {Object} TrashItem
* @property {string} id
* @property {string} original_id
* @property {ItemTypeEnum} item_type
* @property {string} name
* @property {string} original_path - timestamp
* @property {number} trashed_at
* @property {number} days_until_deletion
* @property {string} category
* @property {string} icon_class
* @property {string} icon_special_class
*/
/**
* @typedef {Object} User
* @property {string} id
* @property {string} username
* @property {string} email
* @property {string} role
* @property {number} storage_quota_bytes
* @property {number} storage_used_bytes
* @property {number} created_at
* @property {number} updated_at
* @property {number} last_login_at
* @property {boolean} active
* @property {string} auth_provider
*/
/**
* @typedef {Object} AuthResponse
* @property {User} user
* @property {String} access_token
* @property {String} refresh_token
* @property {String} token_type
* @property {number} expires_in
*/
/**
* @typedef {'user' | 'admin'} RoleEnum
*/
/**
* @typedef {"relevance" | "name" | "name_desc" | "date" | "date_desc" | "size" | "size_desc"} SortByEnnum
*/
/**
* @typedef {Object} SearchCriteria
* @property {SortByEnnum} sort_by
* @property {boolean} recursive
* @property {number} limit
* @property {number} offset
*
* @property {String} [name_contains]
* @property {String[]} [file_types] pdf, jpg, ...
* @property {String} [folder_id]
*
*
* @property {number} [min_size]
* @property {number} [max_size]
*
* @property {number} [created_before]
* @property {number} [created_after]
*
* @property {number} [modified_before]
* @property {number} [modified_after]
*/
/**
* @typedef {Object} SearchResults
* FIXME: is in fact Vec<SearchFileResultDto>,
* @property {FileItem[]} files
* FIXME: is infact Vec<SearchFolderResultDto>,
* @property {FolderItem[]} folders:
* @property {number | null} total_count
* @property {number} limit
* @property {number} offset
* @property {boolean} has_more
* @property {number} query_time_ms
* @property {string} sort_by
*/
/**
* @typedef {Object} Playlist
* @property {String} id
* @property {String} name
* @property {String | null} description
* @property {String} owner_id
* @property {boolean} is_public
* @property {String | null} cover_file_id
* @property {number} track_count
* @property {number} total_duration_secs
* @property {number} created_at
* @property {number} updated_at
*/
/**
* @typedef {Object} PlaylistItem
* @property {String} id
* @property {String} playlist_id
* @property {String} file_id
* @property {number} position
* @property {number} added_at
* @property {String|null} file_name
* @property {number|null} file_size
* @property {String|null} mime_type
* @property {String|null} title
* @property {String|null} artist
* @property {String|null} album
* @property {number|null} duration_secs
*/
/**
* @typedef {Object} Musicshare
* @property {String} user_id
* @property {boolean|null} can_write
*/
/**
* @typedef {Object} FileMetadata
* @property {String} file_id
* @property {number} captured_at
* @property {number|null} latitude
* @property {number|null} longitude
* @property {String|null} camera_make
* @property {String|null} camera_model
* @property {number|null} orientation
* @property {number|null} width
* @property {number|null} height
*/
+58 -5
View File
@@ -6,6 +6,10 @@
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
/**
* @import {AuthResponse, RoleEnum, User} from '../../core/types.js'
*/
// API endpoints // API endpoints
const API_URL = '/api/auth'; const API_URL = '/api/auth';
const LOGIN_ENDPOINT = `${API_URL}/login`; const LOGIN_ENDPOINT = `${API_URL}/login`;
@@ -41,6 +45,18 @@ function inputVal(id) {
} }
// Language selector texts (used before i18n is loaded) // Language selector texts (used before i18n is loaded)
/**
* @typedef {Object} PreTranslatedText
* @property {string} title
* @property {string} subtitle
* @property {string} continue
* @property {string} autodetected
* @property {string} moreLanguages
* @property {string} modalTitle
* @property {string} searchPlaceholder
*/
/** @type {Record<String,PreTranslatedText>} */
const LANGUAGE_TEXTS = { const LANGUAGE_TEXTS = {
en: { en: {
title: 'Welcome!', title: 'Welcome!',
@@ -145,6 +161,16 @@ const LANGUAGE_TEXTS = {
// Complete language registry — add new languages here, they'll appear automatically // Complete language registry — add new languages here, they'll appear automatically
// `popular: true` languages show as cards on the main screen, the rest in the modal // `popular: true` languages show as cards on the main screen, the rest in the modal
/**
* @typedef {Object} Lang
* @property {string} code
* @property {string} name
* @property {string} nativeName
* @property {string} flag
* @property {boolean} popular
*/
/** @type {Lang[]} */
export const ALL_LANGUAGES = [ export const ALL_LANGUAGES = [
{ {
code: 'en', code: 'en',
@@ -377,9 +403,18 @@ export const ALL_LANGUAGES = [
// --- Panel visibility helpers --- // --- Panel visibility helpers ---
// The `.hidden` CSS class uses `display: none !important`, so inline // The `.hidden` CSS class uses `display: none !important`, so inline
// `style.display` can never override it. Always toggle the class instead. // `style.display` can never override it. Always toggle the class instead.
/**
*
* @param {HTMLElement} el
*/
function showPanel(el) { function showPanel(el) {
if (el) el.classList.remove('hidden'); if (el) el.classList.remove('hidden');
} }
/**
*
* @param {HTMLElement} el
*/
function hidePanel(el) { function hidePanel(el) {
if (el) el.classList.add('hidden'); if (el) el.classList.add('hidden');
} }
@@ -434,13 +469,18 @@ function detectBrowserLanguage() {
return ALL_LANGUAGES[0]; // fallback to English return ALL_LANGUAGES[0]; // fallback to English
} }
// Build a language option element (card style) /**
* Build a language option element (card style)
* @param {Lang} lang
* @param {boolean} isSelected
* @returns
*/
function buildLanguageCard(lang, isSelected) { function buildLanguageCard(lang, isSelected) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `lang-picker-item${isSelected ? ' selected' : ''}`; item.className = `lang-picker-item${isSelected ? ' selected' : ''}`;
item.setAttribute('data-lang', lang.code); item.setAttribute('data-lang', lang.code);
item.setAttribute('role', 'option'); item.setAttribute('role', 'option');
item.setAttribute('aria-selected', isSelected); item.setAttribute('aria-selected', String(isSelected));
item.innerHTML = ` item.innerHTML = `
<span class="lang-picker-item-flag">${lang.flag}</span> <span class="lang-picker-item-flag">${lang.flag}</span>
<span class="lang-picker-item-name">${lang.nativeName}</span> <span class="lang-picker-item-name">${lang.nativeName}</span>
@@ -596,7 +636,10 @@ function initLanguageSelector() {
}); });
} }
// Update language panel texts based on selected language /**
* Update language panel texts based on selected language
* @param {string} lang
*/
function updateLanguagePanelTexts(lang) { function updateLanguagePanelTexts(lang) {
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en; const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
const titleEl = document.getElementById('language-title'); const titleEl = document.getElementById('language-title');
@@ -1089,6 +1132,9 @@ if (isLoginPage && adminSetupForm) {
/** /**
* Login with username and password * Login with username and password
* @param {string} username
* @param {string} password
* @returns {Promise<AuthResponse>}
*/ */
async function login(username, password) { async function login(username, password) {
try { try {
@@ -1126,8 +1172,9 @@ async function login(username, password) {
// Parse the JSON response // Parse the JSON response
try { try {
/** @type {AuthResponse} */
const data = await response.json(); const data = await response.json();
console.log('Login successful, received data'); console.log(`Login successful for user id ${data.user.id}, received data`);
return data; return data;
} catch (jsonError) { } catch (jsonError) {
console.error('Error parsing login response:', jsonError); console.error('Error parsing login response:', jsonError);
@@ -1141,6 +1188,11 @@ async function login(username, password) {
/** /**
* Register a new user * Register a new user
* @param {string} username
* @param {string} email
* @param {string} password
* @param {RoleEnum} [role]
* @returns {Promise<User>}
*/ */
async function register(username, email, password, role = 'user') { async function register(username, email, password, role = 'user') {
try { try {
@@ -1170,8 +1222,9 @@ async function register(username, email, password, role = 'user') {
// Parse the JSON response // Parse the JSON response
try { try {
/** @type {User} */
const data = await response.json(); const data = await response.json();
console.log('Registration successful, received data'); console.log(`Registration successful, user created: ${data.id}, received data`);
return data; return data;
} catch (jsonError) { } catch (jsonError) {
console.error('Error parsing registration response:', jsonError); console.error('Error parsing registration response:', jsonError);
+49 -15
View File
@@ -20,10 +20,19 @@ import { inlineViewer } from './inlineViewer.js';
import { multiSelect } from './multiSelect.js'; import { multiSelect } from './multiSelect.js';
import { wopiEditor } from './wopiEditor.js'; import { wopiEditor } from './wopiEditor.js';
/**
* @import {FolderItem, FileItem, ItemTypeEnum, Playlist} from '../../core/types.js'
*/
/** @type {EventListener | null} */
let _moveDialogEscapeHandler = null; let _moveDialogEscapeHandler = null;
// Context Menus Module // Context Menus Module
const contextMenus = { const contextMenus = {
/**
* @param {string} optionId
* @param {boolean} isFavorite
*/
_setFavoriteOptionLabel(optionId, isFavorite) { _setFavoriteOptionLabel(optionId, isFavorite) {
const option = document.getElementById(optionId); const option = document.getElementById(optionId);
if (!option) return; if (!option) return;
@@ -308,11 +317,13 @@ const contextMenus = {
// Note: We don't use stopPropagation because all Escape handlers are on document level // Note: We don't use stopPropagation because all Escape handlers are on document level
// Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses // Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses
if (!_moveDialogEscapeHandler) { if (!_moveDialogEscapeHandler) {
_moveDialogEscapeHandler = (e) => { _moveDialogEscapeHandler = /** @type {EventListener} */ (
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) { (/** @type {KeyboardEvent} */ e) => {
this.closeMoveDialog(); if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
this.closeMoveDialog();
}
} }
}; );
document.addEventListener('keydown', _moveDialogEscapeHandler); document.addEventListener('keydown', _moveDialogEscapeHandler);
} }
@@ -385,8 +396,8 @@ const contextMenus = {
/** /**
* Show move dialog for a file or folder * Show move dialog for a file or folder
* @param {Object} item - File or folder object * @param {FolderItem | FileItem} item - File or folder object
* @param {string} mode - 'file' or 'folder' * @param {ItemTypeEnum} mode
*/ */
async showMoveDialog(item, mode) { async showMoveDialog(item, mode) {
// Set mode // Set mode
@@ -405,15 +416,15 @@ const contextMenus = {
// Start at the parent of the item being moved (so user sees siblings and can navigate) // Start at the parent of the item being moved (so user sees siblings and can navigate)
let startFolderId = null; let startFolderId = null;
let startFolderName = null; let startFolderName = null;
if (mode === 'file' && item.folder_id) { if (mode === 'file' && /** @type {FileItem} */ (item).folder_id) {
startFolderId = item.folder_id; startFolderId = /** @type {FileItem} */ (item).folder_id;
// We need the folder name for breadcrumb - try to get it from current view // We need the folder name for breadcrumb - try to get it from current view
const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`); const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`);
if (folderEl) { if (folderEl) {
startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null; startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null;
} }
} else if (mode === 'folder' && item.parent_id) { } else if (mode === 'folder' && /** @type {FolderItem} */ (item).parent_id) {
startFolderId = item.parent_id; startFolderId = /** @type {FolderItem} */ (item).parent_id;
} else { } else {
// If item is at root level, start at user's home folder // If item is at root level, start at user's home folder
startFolderId = app.userHomeFolderId || null; startFolderId = app.userHomeFolderId || null;
@@ -492,6 +503,7 @@ const contextMenus = {
// The contents endpoint returns an array of child folders // The contents endpoint returns an array of child folders
// The fallback /api/folders returns root folders (home folder itself) // The fallback /api/folders returns root folders (home folder itself)
/** @type {FolderItem[]} */
const folders = Array.isArray(data) ? data : data.folders || []; const folders = Array.isArray(data) ? data : data.folders || [];
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders); console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
@@ -623,6 +635,9 @@ const contextMenus = {
/** /**
* Render breadcrumb navigation for move dialog * Render breadcrumb navigation for move dialog
* @param {HTMLElement | null} container
* @param {Array<{id: string, name: string}>} breadcrumb
* @param {string | null} _currentFolderId
*/ */
_renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) { _renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) {
if (!container) return; if (!container) return;
@@ -666,7 +681,7 @@ const contextMenus = {
} }
// Breadcrumb path // Breadcrumb path
breadcrumb.forEach((segment, index) => { breadcrumb.forEach((/** @type {{id: string, name: string}} */ segment, /** @type {number} */ index) => {
const separator = document.createElement('span'); const separator = document.createElement('span');
separator.className = 'move-breadcrumb-separator'; separator.className = 'move-breadcrumb-separator';
separator.textContent = '>'; separator.textContent = '>';
@@ -709,8 +724,8 @@ const contextMenus = {
/** /**
* Show share dialog for files or folders * Show share dialog for files or folders
* @param {Object} item - File or folder object * @param {FileItem | FolderItem} item - File or folder object
* @param {string} itemType - 'file' or 'folder' * @param {ItemTypeEnum} itemType
*/ */
async showShareDialog(item, itemType) { async showShareDialog(item, itemType) {
try { try {
@@ -835,7 +850,7 @@ const contextMenus = {
btn.closest('.existing-share-item').remove(); btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) { if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').classList.add('hidden'); document.getElementById('existing-shares-section').classList.add('hidden');
ui.setSharedVisualState(item.id, item.type, false); ui.setSharedVisualState(item.id, itemType, false);
} }
} }
}); });
@@ -920,7 +935,7 @@ const contextMenus = {
} }
// Update Item's shared badge // Update Item's shared badge
ui.setSharedVisualState(item.id, item.type, true); ui.setSharedVisualState(item.id, itemType, true);
// Show success message // Show success message
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success')); ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
@@ -994,8 +1009,14 @@ const contextMenus = {
app.notificationShareUrl = null; app.notificationShareUrl = null;
}, },
/** @type {String | null} */
_selectedPlaylistId: null, _selectedPlaylistId: null,
/**
*
* @param {FileItem} file
* @returns
*/
async showPlaylistDialog(file) { async showPlaylistDialog(file) {
const dialog = document.getElementById('playlist-dialog'); const dialog = document.getElementById('playlist-dialog');
const container = document.getElementById('playlist-select-container'); const container = document.getElementById('playlist-select-container');
@@ -1031,6 +1052,7 @@ const contextMenus = {
const resp = await fetch('/api/playlists', { credentials: 'include' }); const resp = await fetch('/api/playlists', { credentials: 'include' });
if (!resp.ok) throw new Error('Failed to load playlists'); if (!resp.ok) throw new Error('Failed to load playlists');
/** @type {Playlist[]} */
const playlists = await resp.json(); const playlists = await resp.json();
this._renderPlaylistSelect(container, playlists); this._renderPlaylistSelect(container, playlists);
} catch (err) { } catch (err) {
@@ -1039,6 +1061,12 @@ const contextMenus = {
} }
}, },
/**
*
* @param {HTMLElement} container
* @param {Playlist[]} playlists
* @returns
*/
_renderPlaylistSelect(container, playlists) { _renderPlaylistSelect(container, playlists) {
container.innerHTML = ''; container.innerHTML = '';
@@ -1124,6 +1152,12 @@ const contextMenus = {
this._selectedPlaylistId = null; this._selectedPlaylistId = null;
}, },
/**
*
* @param {string} str
* @returns
*/
//FIXME: move to common library
_escapeHtml(str) { _escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+74 -31
View File
@@ -11,10 +11,18 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
/** @import {TrashItem} from '../../core/types.js' */
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/** /**
* Get authorization headers for API requests. * Get authorization headers for API requests.
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed. * Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
* @returns {Object} Headers object * @returns {Record<String, String>} Headers object
*/ */
function getAuthHeaders() { function getAuthHeaders() {
return { ...getCsrfHeaders() }; return { ...getCsrfHeaders() };
@@ -25,15 +33,26 @@ const fileOps = {
// ======================================================================== // ========================================================================
// Upload progress — notification bell integration // Upload progress — notification bell integration
// ======================================================================== // ========================================================================
/** @type {string | null} */
_currentBatchId: null, _currentBatchId: null,
/** @type {boolean} */
_isUploading: false, // Guard against concurrent upload calls _isUploading: false, // Guard against concurrent upload calls
/** Start a new upload batch in the notification bell */ /**
* Start a new upload batch in the notification bell
* @param {number} totalFiles
* @param {string} [folderName]
*/
_initUploadToast(totalFiles, folderName) { _initUploadToast(totalFiles, folderName) {
this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName); this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName);
}, },
/** Finalise the batch in the notification bell */ /**
* Finalise the batch in the notification bell
* @param {number} successCount
* @param {number} totalFiles
* */
_finishUploadToast(successCount, totalFiles) { _finishUploadToast(successCount, totalFiles) {
if (this._currentBatchId) { if (this._currentBatchId) {
notifications.finishBatch(this._currentBatchId, successCount, totalFiles); notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
@@ -44,6 +63,8 @@ const fileOps = {
* Some drag-and-drop sources can inject directory placeholders into * Some drag-and-drop sources can inject directory placeholders into
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED * DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
* when trying to send them as normal files. * when trying to send them as normal files.
* @param {File} file
* @returns {Promise<boolean>}
*/ */
_canReadFileBlob(file) { _canReadFileBlob(file) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -58,10 +79,24 @@ const fileOps = {
}); });
}, },
// FIXME: prefer exceptions for errors
/**
* @typedef {Object} UploadAnswer
* @property {boolean} ok
* @property {any} [data]
* @property {string} [errorMsg]
* @property {boolean} [isQuotaError]
* @property {boolean} [isTimeout]
*/
/** /**
* Upload a single file via XMLHttpRequest with progress events. * Upload a single file via XMLHttpRequest with progress events.
* Progress is reported to the notification bell via batchId + fileName. * Progress is reported to the notification bell via batchId + fileName.
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }. * Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
* @param {FormData} formData
* @param {string} batchId
* @param {string} fileName
* @param {number} [timeoutMs=120000]
*/ */
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) { _uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -76,9 +111,16 @@ const fileOps = {
let lastProgressPctSent = -1; let lastProgressPctSent = -1;
let isSettled = false; let isSettled = false;
/** @type {ReturnType<typeof setTimeout>} */
let stallTimer = null; let stallTimer = null;
/** @type {ReturnType<typeof setTimeout>} */
let hardTimer = null; let hardTimer = null;
/**
*
* @param {number} pct
* @param {'uploading' | 'done' | 'error'} status
*/
const safeUpdateFile = (pct, status) => { const safeUpdateFile = (pct, status) => {
if (!notif || !batchId) return; if (!notif || !batchId) return;
try { try {
@@ -88,6 +130,11 @@ const fileOps = {
} }
}; };
/**
*
* @param {UploadAnswer} result
* @returns
*/
const finalize = (result) => { const finalize = (result) => {
if (isSettled) return; if (isSettled) return;
isSettled = true; isSettled = true;
@@ -219,6 +266,12 @@ const fileOps = {
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries. * Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }. * Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
*/ */
/**
*
* @param {*} formData
* @param {*} timeoutMs
* @returns {Promise<UploadAnswer>}
*/
async _uploadFileFetch(formData, timeoutMs = 60000) { async _uploadFileFetch(formData, timeoutMs = 60000) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -254,11 +307,13 @@ const fileOps = {
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507; const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
return { ok: false, errorMsg, isQuotaError }; return { ok: false, errorMsg, isQuotaError };
} catch (e) { } catch (e) {
const isTimeout = e?.name === 'AbortError'; const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError';
return { return {
ok: false, ok: false,
isTimeout, isTimeout,
errorMsg: isTimeout ? `Timeout after ${Math.round(timeoutMs / 1000)}s` : `Fetch upload failed: ${e?.message || 'network error'}` errorMsg: isTimeout
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
: `Fetch upload failed: ${/** @type {Error} */ (e)?.message || 'network error'}`
}; };
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -458,6 +513,7 @@ const fileOps = {
try { try {
// Filter unreadable entries // Filter unreadable entries
/** @type {Array<{file: File, relativePath: string}>} */
const validEntries = []; const validEntries = [];
for (const e of rawEntries) { for (const e of rawEntries) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
@@ -560,12 +616,18 @@ const fileOps = {
const TIMEOUT_MIN_MS = 10000; // floor for tiny files const TIMEOUT_MIN_MS = 10000; // floor for tiny files
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
/**
*
* @param {number} idx
* @returns
*/
const uploadOneFile = async (idx) => { const uploadOneFile = async (idx) => {
if (quotaStop) return; if (quotaStop) return;
const entry = validEntries[idx]; const entry = validEntries[idx];
const file = entry.file; const file = entry.file;
const rel = entry.relativePath || file.name; const rel = entry.relativePath || file.name;
/** @type {UploadAnswer} */
let result = { ok: false, errorMsg: 'Unknown client error' }; let result = { ok: false, errorMsg: 'Unknown client error' };
try { try {
const parts = rel.split('/'); const parts = rel.split('/');
@@ -577,6 +639,7 @@ const fileOps = {
// but block on open(). Pre-read only 0-byte files into // but block on open(). Pre-read only 0-byte files into
// memory; files with size>0 are always regular files and // memory; files with size>0 are always regular files and
// go straight to FormData (zero extra memory copy). // go straight to FormData (zero extra memory copy).
/** @type {Blob} */
let uploadFile = file; // default: use original File let uploadFile = file; // default: use original File
if (file.size === 0) { if (file.size === 0) {
try { try {
@@ -616,7 +679,7 @@ const fileOps = {
} catch (e) { } catch (e) {
result = { result = {
ok: false, ok: false,
errorMsg: `Client exception: ${e?.message || 'unknown'}` errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}`
}; };
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e); console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
} }
@@ -823,12 +886,6 @@ const fileOps = {
} }
}, },
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/** /**
* Move files & folders * Move files & folders
* @param {string[]} fileIds - File IDs * @param {string[]} fileIds - File IDs
@@ -941,18 +998,12 @@ const fileOps = {
return res.ok; return res.ok;
}, },
/**
* @typedef {Object} BatchCopyReturn
* @property {number} success
* @property {number} errors
*/
/** /**
* Copy files & folders * Copy files & folders
* @param {string[]} fileIds - File IDs * @param {string[]} fileIds - File IDs
* @param {string[]} folderIds - Folder IDs * @param {string[]} folderIds - Folder IDs
* @param {string} targetFolderId - Target folder ID * @param {string} targetFolderId - Target folder ID
* @returns {Promise<BatchCopyReturn>} - Success status * @returns {Promise<BatchResult>} - Success status
*/ */
async batchCopy(fileIds, folderIds, targetFolderId) { async batchCopy(fileIds, folderIds, targetFolderId) {
// FIXME ensure not moving a folder into itself // FIXME ensure not moving a folder into itself
@@ -1010,7 +1061,6 @@ const fileOps = {
* Rename a file * Rename a file
* @param {string} fileId - File ID * @param {string} fileId - File ID
* @param {string} newName - New file name * @param {string} newName - New file name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/ */
async renameFile(fileId, newName) { async renameFile(fileId, newName) {
try { try {
@@ -1052,13 +1102,6 @@ const fileOps = {
* Rename a folder * Rename a folder
* @param {string} folderId - Folder ID * @param {string} folderId - Folder ID
* @param {string} newName - New folder name * @param {string} newName - New folder name
* @returns {Promise<boolean>} - Success status
*/
/**
* Rename a folder
* @param {string} folderId - Folder ID
* @param {string} newName - New folder name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/ */
async renameFolder(folderId, newName) { async renameFolder(folderId, newName) {
try { try {
@@ -1170,7 +1213,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up // If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) { if (app.currentPath === folderId) {
app.currentPath = ''; app.currentPath = '';
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
} }
loadFiles(); loadFiles();
ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`); ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`);
@@ -1186,7 +1229,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up // If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) { if (app.currentPath === folderId) {
app.currentPath = ''; app.currentPath = '';
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
} }
loadFiles(); loadFiles();
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`); ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
@@ -1205,7 +1248,7 @@ const fileOps = {
/** /**
* Get trash items * Get trash items
* @returns {Promise<Array>} - List of trash items * @returns {Promise<Array<TrashItem>>} - List of trash items
*/ */
async getTrashItems() { async getTrashItems() {
try { try {
@@ -1214,7 +1257,7 @@ const fileOps = {
}); });
if (response.ok) { if (response.ok) {
return await response.json(); return /** @type {TrashItem[]} */ (await response.json());
} else { } else {
console.error('Error fetching trash items:', response.statusText); console.error('Error fetching trash items:', response.statusText);
return []; return [];
+64 -32
View File
@@ -8,6 +8,8 @@ import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js'; import { isTextViewable } from '../../core/formatters.js';
import { wopiEditor } from './wopiEditor.js'; import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */
class InlineViewer { class InlineViewer {
constructor() { constructor() {
this.setupViewer(); this.setupViewer();
@@ -93,6 +95,11 @@ class InlineViewer {
console.log('Inline viewer initialized'); console.log('Inline viewer initialized');
} }
/**
*
* @param {FileItem} file
* @returns
*/
async openFile(file) { async openFile(file) {
console.log('Opening file:', file); console.log('Opening file:', file);
@@ -115,7 +122,7 @@ class InlineViewer {
// Get container // Get container
const modal = document.getElementById('inline-viewer-modal'); const modal = document.getElementById('inline-viewer-modal');
const container = modal.querySelector('.inline-viewer-container'); const container = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-container'));
const title = modal.querySelector('.inline-viewer-title'); const title = modal.querySelector('.inline-viewer-title');
// Clear container // Clear container
@@ -125,12 +132,12 @@ class InlineViewer {
title.textContent = file.name; title.textContent = file.name;
// Set controls visibility // Set controls visibility
const controls = modal.querySelector('.inline-viewer-controls'); const controls = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-controls'));
// Show viewer based on file type // Show viewer based on file type
if (isImage) { if (isImage) {
// Show zoom controls // Show zoom controls
controls.style.display = 'flex'; controls.classList.remove('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -142,7 +149,7 @@ class InlineViewer {
this.createBlobUrlViewer(file, 'image', container, loader); this.createBlobUrlViewer(file, 'image', container, loader);
} else if (file.mime_type && file.mime_type === 'application/pdf') { } else if (file.mime_type && file.mime_type === 'application/pdf') {
// Hide zoom controls for PDFs // Hide zoom controls for PDFs
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -152,9 +159,9 @@ class InlineViewer {
// Create PDF viewer using object tag with blob URL // Create PDF viewer using object tag with blob URL
this.createBlobUrlViewer(file, 'pdf', container, loader); this.createBlobUrlViewer(file, 'pdf', container, loader);
} else if (file.mime_type && this.isTextViewable(file.mime_type)) { } else if (file.mime_type && isTextViewable(file.mime_type)) {
// Hide zoom controls for text files // Hide zoom controls for text files
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -166,7 +173,7 @@ class InlineViewer {
this.createTextViewer(file, container, loader); this.createTextViewer(file, container, loader);
} else if (file.mime_type?.startsWith('audio/')) { } else if (file.mime_type?.startsWith('audio/')) {
// Hide zoom controls for audio // Hide zoom controls for audio
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -178,7 +185,7 @@ class InlineViewer {
this.createMediaViewer(file, 'audio', container, loader); this.createMediaViewer(file, 'audio', container, loader);
} else if (file.mime_type?.startsWith('video/')) { } else if (file.mime_type?.startsWith('video/')) {
// Hide zoom controls for video // Hide zoom controls for video
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -190,7 +197,7 @@ class InlineViewer {
this.createMediaViewer(file, 'video', container, loader); this.createMediaViewer(file, 'video', container, loader);
} else { } else {
// Hide zoom controls for unsupported files // Hide zoom controls for unsupported files
controls.style.display = 'none'; controls.classList.add('hidden');
// Show unsupported file message // Show unsupported file message
const message = document.createElement('div'); const message = document.createElement('div');
@@ -209,12 +216,13 @@ class InlineViewer {
modal.classList.add('active'); modal.classList.add('active');
} }
// Check if a MIME type is text-viewable
isTextViewable(mimeType) {
return isTextViewable(mimeType);
}
// Creates a text viewer using authenticated fetch // Creates a text viewer using authenticated fetch
/**
*
* @param {FileItem} file
* @param {HTMLDivElement} container
* @param {*} loader
*/
async createTextViewer(file, container, loader) { async createTextViewer(file, container, loader) {
try { try {
console.log('Creating text viewer for:', file.name); console.log('Creating text viewer for:', file.name);
@@ -253,14 +261,20 @@ class InlineViewer {
} }
} }
// Creates a viewer using a Blob URL to avoid content-disposition header /**
async createBlobUrlViewer(file, type, container, loader) { * Creates a viewer using a Blob URL to avoid content-disposition header
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createBlobUrlViewer(file, mediaType, container, loader) {
try { try {
console.log('Creating blob URL viewer for:', file.name, 'type:', type); console.log('Creating blob URL viewer for:', file.name, 'type:', mediaType);
// Update loader to show progress bar for large files // Update loader to show progress bar for large files
let progressBar = null; let progressBar = /** @type {HTMLElement|null} */ (null);
let progressText = null; let progressText = /** @type {HTMLElement|null} */ (null);
if (loader && file.size > 10 * 1024 * 1024) { if (loader && file.size > 10 * 1024 * 1024) {
// Show progress for files > 10MB // Show progress for files > 10MB
loader.innerHTML = ` loader.innerHTML = `
@@ -272,8 +286,8 @@ class InlineViewer {
<div class="inline-viewer-progress-text">0%</div> <div class="inline-viewer-progress-text">0%</div>
</div> </div>
`; `;
progressBar = loader.querySelector('.inline-viewer-progress-fill'); progressBar = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-fill'));
progressText = loader.querySelector('.inline-viewer-progress-text'); progressText = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-text'));
} }
// Use XMLHttpRequest instead of fetch to get better control over the response // Use XMLHttpRequest instead of fetch to get better control over the response
@@ -322,7 +336,7 @@ class InlineViewer {
loader.parentNode.removeChild(loader); loader.parentNode.removeChild(loader);
} }
if (type === 'image') { if (mediaType === 'image') {
console.log('Creating image viewer'); console.log('Creating image viewer');
// Create image element // Create image element
const img = document.createElement('img'); const img = document.createElement('img');
@@ -332,10 +346,10 @@ class InlineViewer {
container.appendChild(img); container.appendChild(img);
// Add loading indicator until image loads // Add loading indicator until image loads
img.style.opacity = 0; img.style.opacity = String(0);
img.onload = () => { img.onload = () => {
console.log('Image loaded successfully'); console.log('Image loaded successfully');
img.style.opacity = 1; img.style.opacity = String(1);
}; };
img.onerror = () => { img.onerror = () => {
@@ -343,7 +357,7 @@ class InlineViewer {
container.removeChild(img); container.removeChild(img);
this.showErrorMessage(container); this.showErrorMessage(container);
}; };
} else if (type === 'pdf') { } else if (mediaType === 'pdf') {
console.log('Creating PDF viewer'); console.log('Creating PDF viewer');
// Create iframe for PDF (more reliable than object tag) // Create iframe for PDF (more reliable than object tag)
@@ -382,7 +396,13 @@ class InlineViewer {
} }
} }
// Creates an audio or video player using blob URL (authenticated fetch) /**
* Creates an audio or video player using blob URL (authenticated fetch)
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createMediaViewer(file, mediaType, container, loader) { async createMediaViewer(file, mediaType, container, loader) {
try { try {
console.log(`Creating ${mediaType} player for:`, file.name); console.log(`Creating ${mediaType} player for:`, file.name);
@@ -485,7 +505,10 @@ class InlineViewer {
} }
} }
// Helper to show error message /**
* Helper to show error message
* @param {HTMLDivElement} container
*/
showErrorMessage(container) { showErrorMessage(container) {
// Show error message // Show error message
const message = document.createElement('div'); const message = document.createElement('div');
@@ -505,7 +528,7 @@ class InlineViewer {
const modal = document.getElementById('inline-viewer-modal'); const modal = document.getElementById('inline-viewer-modal');
// stops audio/video before closing viewver // stops audio/video before closing viewver
const media = modal.querySelector('audio, video'); const media = /** @type {HTMLMediaElement} */ (modal.querySelector('audio, video'));
if (media && !media.paused) media.pause(); if (media && !media.paused) media.pause();
// Hide modal // Hide modal
@@ -525,6 +548,10 @@ class InlineViewer {
this.currentFile = null; this.currentFile = null;
} }
/**
*
* @param {FileItem} file
*/
downloadFile(file) { downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' }) fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
.then((res) => { .then((res) => {
@@ -544,9 +571,14 @@ class InlineViewer {
.catch((err) => console.error('Download error:', err)); .catch((err) => console.error('Download error:', err));
} }
/**
*
* @param {number} factor
* @returns
*/
zoomImage(factor) { zoomImage(factor) {
const container = document.querySelector('.inline-viewer-container'); const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image'); const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return; if (!img) return;
@@ -560,7 +592,7 @@ class InlineViewer {
scale = Math.max(0.1, Math.min(5.0, scale)); scale = Math.max(0.1, Math.min(5.0, scale));
// Save scale // Save scale
img.dataset.scale = scale; img.dataset.scale = String(scale);
// Apply scale // Apply scale
img.style.transform = `scale(${scale})`; img.style.transform = `scale(${scale})`;
@@ -568,12 +600,12 @@ class InlineViewer {
resetZoom() { resetZoom() {
const container = document.querySelector('.inline-viewer-container'); const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image'); const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return; if (!img) return;
// Reset scale // Reset scale
img.dataset.scale = 1.0; img.dataset.scale = String(1);
img.style.transform = 'scale(1.0)'; img.style.transform = 'scale(1.0)';
} }
} }
+69 -14
View File
@@ -9,8 +9,6 @@
// TODO: rename into selection-bar ? // TODO: rename into selection-bar ?
// TODO: merge with photo part // TODO: merge with photo part
// @ts-check
import { loadFiles } from '../../app/filesView.js'; import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js'; import { showConfirmDialog, ui } from '../../app/ui.js';
@@ -19,8 +17,14 @@ import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js'; import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
/**
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
* @import {BatchResult} from './fileOperations.js'
*/
const multiSelect = { const multiSelect = {
/** Currently selected items: Map<id, { id, name, type, parentId }> */ /** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
_selected: new Map(), _selected: new Map(),
/** Last clicked index for Shift-range selection */ /** Last clicked index for Shift-range selection */
@@ -49,6 +53,12 @@ const multiSelect = {
// ── Helpers for i18n ──────────────────────────────────── // ── Helpers for i18n ────────────────────────────────────
/**
*
* @param {string} key
* @param {any} vars
* @returns
*/
_t(key, vars) { _t(key, vars) {
const val = i18n.t(key, vars); const val = i18n.t(key, vars);
return val !== key ? val : null; return val !== key ? val : null;
@@ -56,6 +66,14 @@ const multiSelect = {
// ── Selection state management ────────────────────────── // ── Selection state management ──────────────────────────
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
toggle(id, name, type, parentId) { toggle(id, name, type, parentId) {
if (this._selected.has(id)) { if (this._selected.has(id)) {
this._selected.delete(id); this._selected.delete(id);
@@ -65,10 +83,22 @@ const multiSelect = {
return true; return true;
}, },
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
select(id, name, type, parentId) { select(id, name, type, parentId) {
this._selected.set(id, { id, name, type, parentId }); this._selected.set(id, { id, name, type, parentId });
}, },
/**
*
* @param {string} id
*/
deselect(id) { deselect(id) {
this._selected.delete(id); this._selected.delete(id);
}, },
@@ -80,7 +110,7 @@ const multiSelect = {
el.classList.remove('selected'); el.classList.remove('selected');
}); });
document.querySelectorAll('.item-checkbox').forEach((cb) => { document.querySelectorAll('.item-checkbox').forEach((cb) => {
cb.checked = false; /** @type {HTMLInputElement} */ (cb).checked = false;
}); });
this._syncUI(); this._syncUI();
}, },
@@ -111,11 +141,13 @@ const multiSelect = {
* @return {ItemSelection} * @return {ItemSelection}
*/ */
getSelection(targtFolderId) { getSelection(targtFolderId) {
/** @type {Array<string>} */
const fileIds = []; const fileIds = [];
/** @type {Array<string>} */
const folderIds = []; const folderIds = [];
// TODO optimize & check if _selected is a better use // TODO optimize & check if _selected is a better use
document.querySelectorAll(`div.file-item.selected`).forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
if (item.dataset.fileId) { if (item.dataset.fileId) {
fileIds.push(item.dataset.fileId); fileIds.push(item.dataset.fileId);
} else { } else {
@@ -152,6 +184,10 @@ const multiSelect = {
// ── DOM helpers ───────────────────────────────────────── // ── DOM helpers ─────────────────────────────────────────
/**
*
* @param {HTMLDivElement} el
*/
_selectElement(el) { _selectElement(el) {
const info = this._extractInfo(el); const info = this._extractInfo(el);
if (info) { if (info) {
@@ -160,18 +196,32 @@ const multiSelect = {
} }
}, },
/**
*
* @param {string} containerId
* @param {string} selector
* @returns {void}
*/
_selectAllInContainer(containerId, selector) { _selectAllInContainer(containerId, selector) {
const container = document.getElementById(containerId); const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId));
if (!container) return; if (!container) return;
container.querySelectorAll(selector).forEach((el) => { /** @type {NodeListOf<HTMLDivElement>} */ (container.querySelectorAll(selector)).forEach((el) => {
this._selectElement(el); this._selectElement(el);
}); });
}, },
/**
*
* @returns {HTMLDivElement[]}
*/
_getAllVisibleItems() { _getAllVisibleItems() {
return [...document.querySelectorAll('.file-item')]; return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]);
}, },
/**
* @param {HTMLDivElement} el
* @returns {LightItem}
*/
_extractInfo(el) { _extractInfo(el) {
if (el.dataset.folderId && el.dataset.folderName !== undefined) { if (el.dataset.folderId && el.dataset.folderName !== undefined) {
return { return {
@@ -194,6 +244,10 @@ const multiSelect = {
// ── Click handler (shared by grid + list) ─────────────── // ── Click handler (shared by grid + list) ───────────────
/**
* @param {HTMLDivElement} el
* @param {MouseEvent} event
*/
handleToggleItem(el, event) { handleToggleItem(el, event) {
const items = this._getAllVisibleItems(); const items = this._getAllVisibleItems();
const index = items.indexOf(el); const index = items.indexOf(el);
@@ -210,7 +264,7 @@ const multiSelect = {
const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`; const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`;
document.querySelectorAll(sel).forEach((e) => { document.querySelectorAll(sel).forEach((e) => {
e.classList.add('selected'); e.classList.add('selected');
const checkbox = e.querySelector('input[type="checkbox"]'); const checkbox = /** @type {HTMLInputElement} */ (e.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = true; if (checkbox) checkbox.checked = true;
}); });
} }
@@ -218,7 +272,7 @@ const multiSelect = {
} else { } else {
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId); const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
el.classList.toggle('selected', nowSelected); el.classList.toggle('selected', nowSelected);
const checkbox = el.querySelector('input[type="checkbox"]'); const checkbox = /** @type {HTMLInputElement} */ (el.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = nowSelected; if (checkbox) checkbox.checked = nowSelected;
} }
this._lastClickedIndex = index; this._lastClickedIndex = index;
@@ -271,13 +325,13 @@ const multiSelect = {
_syncItemCheckboxes() { _syncItemCheckboxes() {
document.querySelectorAll('.file-item').forEach((el) => { document.querySelectorAll('.file-item').forEach((el) => {
const cb = el.querySelector('.item-checkbox'); const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
if (cb) cb.checked = el.classList.contains('selected'); if (cb) cb.checked = el.classList.contains('selected');
}); });
}, },
_syncSelectAllCheckbox() { _syncSelectAllCheckbox() {
const cb = document.getElementById('select-all-checkbox'); const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
if (!cb) return; if (!cb) return;
const all = this._getAllVisibleItems(); const all = this._getAllVisibleItems();
if (all.length === 0) { if (all.length === 0) {
@@ -454,9 +508,10 @@ const multiSelect = {
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return; const target = /** @type {Element} */ (e.target);
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
const selectAllCheckbox = document.getElementById('select-all-checkbox'); const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
// ctrl+a cmd+a // ctrl+a cmd+a
if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if (selectAllCheckbox) selectAllCheckbox.checked = true; if (selectAllCheckbox) selectAllCheckbox.checked = true;
+30 -18
View File
@@ -12,6 +12,10 @@ import { app } from '../../app/state.js';
import { ui } from '../../app/ui.js'; import { ui } from '../../app/ui.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
/**
* @import {SearchCriteria, SearchResults} from '../../core/types.js'}
*/
const search = { const search = {
/** /**
* Perform a search using query parameters. * Perform a search using query parameters.
@@ -19,25 +23,29 @@ const search = {
* relevance_score, icon_class, category, size_formatted, etc. * relevance_score, icon_class, category, size_formatted, etc.
* *
* @param {string} query - Search query * @param {string} query - Search query
* @param {Object} options - Additional search options * @param {SearchCriteria} [options] - Additional search options
* @returns {Promise<Object>} - Enriched search results from backend * @returns {Promise<SearchResults>} - Enriched search results from backend
*/ */
async searchFiles(query, options = {}) { async searchFiles(query, options) {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('query', query); params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id); if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.recursive !== undefined) params.append('recursive', options.recursive); if (options.recursive !== undefined) params.append('recursive', String(options.recursive));
if (options.file_types) params.append('type', options.file_types); if (options.file_types) {
if (options.min_size) params.append('min_size', options.min_size); options.file_types.forEach((file_type) => {
if (options.max_size) params.append('max_size', options.max_size); params.append('type', file_type);
if (options.created_after) params.append('created_after', options.created_after); });
if (options.created_before) params.append('created_before', options.created_before); }
if (options.modified_after) params.append('modified_after', options.modified_after); if (options.min_size) params.append('min_size', String(options.min_size));
if (options.modified_before) params.append('modified_before', options.modified_before); if (options.max_size) params.append('max_size', String(options.max_size));
if (options.limit) params.append('limit', options.limit); if (options.created_after) params.append('created_after', String(options.created_after));
if (options.offset) params.append('offset', options.offset); if (options.created_before) params.append('created_before', String(options.created_before));
if (options.modified_after) params.append('modified_after', String(options.modified_after));
if (options.modified_before) params.append('modified_before', String(options.modified_before));
if (options.limit) params.append('limit', String(options.limit));
if (options.offset) params.append('offset', String(options.offset));
if (options.sort_by) params.append('sort_by', options.sort_by); if (options.sort_by) params.append('sort_by', options.sort_by);
const url = `/api/search?${params.toString()}`; const url = `/api/search?${params.toString()}`;
@@ -46,6 +54,7 @@ const search = {
const response = await fetch(url, { headers: getAuthHeaders() }); const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) { if (response.ok) {
/** @type {SearchResults} */
return await response.json(); return await response.json();
} else { } else {
let errorText = ''; let errorText = '';
@@ -66,7 +75,10 @@ const search = {
folders: [], folders: [],
total_count: 0, total_count: 0,
query_time_ms: 0, query_time_ms: 0,
sort_by: 'relevance' sort_by: 'relevance',
limit: 0,
offset: 0,
has_more: false
}; };
} }
}, },
@@ -76,15 +88,15 @@ const search = {
* Returns lightweight name suggestions without full search overhead. * Returns lightweight name suggestions without full search overhead.
* *
* @param {string} query - Prefix to search for * @param {string} query - Prefix to search for
* @param {Object} options - { folder_id, limit } * @param {SearchCriteria} [options] - { folder_id, limit }
* @returns {Promise<Object>} - { suggestions: [...], query_time_ms } * @returns {Promise<Object>} - { suggestions: [...], query_time_ms }
*/ */
async getSuggestions(query, options = {}) { async getSuggestions(query, options) {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('query', query); params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id); if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.limit) params.append('limit', options.limit); if (options.limit) params.append('limit', String(options.limit));
const url = `/api/search/suggest?${params.toString()}`; const url = `/api/search/suggest?${params.toString()}`;
const response = await fetch(url, { headers: getAuthHeaders() }); const response = await fetch(url, { headers: getAuthHeaders() });
@@ -111,7 +123,7 @@ const search = {
* - query_time_ms: Server-side query execution time * - query_time_ms: Server-side query execution time
* - sort_by: Active sort order * - sort_by: Active sort order
* *
* @param {Object} results - Enriched search results from backend * @param {SearchResults} results - Enriched search results from backend
*/ */
displaySearchResults(results) { displaySearchResults(results) {
ui.resetFilesList(); // ensure also list visible & error hidden ui.resetFilesList(); // ensure also list visible & error hidden
+23 -4
View File
@@ -19,6 +19,7 @@ class WopiEditor {
/** /**
* Check if a file can be opened in a WOPI editor by extension. * Check if a file can be opened in a WOPI editor by extension.
* Fetches supported extensions from the server (cached after first call). * Fetches supported extensions from the server (cached after first call).
* @param {string} filename
*/ */
async canEdit(filename) { async canEdit(filename) {
const ext = filename.split('.').pop().toLowerCase(); const ext = filename.split('.').pop().toLowerCase();
@@ -28,6 +29,9 @@ class WopiEditor {
/** /**
* Open file in a modal overlay (default mode). * Open file in a modal overlay (default mode).
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/ */
async openInModal(fileId, fileName, action) { async openInModal(fileId, fileName, action) {
action = action || 'edit'; action = action || 'edit';
@@ -38,6 +42,9 @@ class WopiEditor {
/** /**
* Open file in a new browser tab. * Open file in a new browser tab.
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/ */
async openInTab(fileId, fileName, action) { async openInTab(fileId, fileName, action) {
action = action || 'edit'; action = action || 'edit';
@@ -53,6 +60,8 @@ class WopiEditor {
/** /**
* Fetch editor URL and WOPI token from the backend. * Fetch editor URL and WOPI token from the backend.
* @param {string} fileId
* @param {string} action
*/ */
async _getEditorUrl(fileId, action) { async _getEditorUrl(fileId, action) {
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, { const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
@@ -68,6 +77,9 @@ class WopiEditor {
/** /**
* Some WOPI file types, such as PDFs, are view-only. * Some WOPI file types, such as PDFs, are view-only.
* If an edit request returns 422, retry once in view mode. * If an edit request returns 422, retry once in view mode.
* @param {string} fileId
* @param {string} fileName
* @param {string} action
*/ */
async _getEditorUrlWithFallback(fileId, fileName, action) { async _getEditorUrlWithFallback(fileId, fileName, action) {
try { try {
@@ -81,6 +93,11 @@ class WopiEditor {
} }
} }
/**
* @param {string} fileName
* @param {string} action
* @param {any} error
*/
_shouldRetryInViewMode(fileName, action, error) { _shouldRetryInViewMode(fileName, action, error) {
if (action !== 'edit' || !error || !error.message) { if (action !== 'edit' || !error || !error.message) {
return false; return false;
@@ -92,6 +109,8 @@ class WopiEditor {
/** /**
* Show the editor in a full-screen modal with iframe. * Show the editor in a full-screen modal with iframe.
* @param {Record<string, any>} editorData
* @param {string} fileName
*/ */
_showModal(editorData, fileName) { _showModal(editorData, fileName) {
this.closeEditor(); this.closeEditor();
@@ -160,13 +179,13 @@ class WopiEditor {
document.body.appendChild(modal); document.body.appendChild(modal);
// ESC key handler // ESC key handler
this._escHandler = function (e) { this._escHandler = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape') this.closeEditor(); if (e.key === 'Escape') this.closeEditor();
}.bind(this); };
document.addEventListener('keydown', this._escHandler); document.addEventListener('keydown', this._escHandler);
// Fix 7: Listen for postMessage from the editor iframe // Fix 7: Listen for postMessage from the editor iframe
this._messageHandler = function (e) { this._messageHandler = (/** @type {MessageEvent} */ e) => {
var data; var data;
try { try {
data = JSON.parse(e.data); data = JSON.parse(e.data);
@@ -183,7 +202,7 @@ class WopiEditor {
if (sp) sp.remove(); if (sp) sp.remove();
} }
} }
}.bind(this); };
window.addEventListener('message', this._messageHandler); window.addEventListener('message', this._messageHandler);
form.submit(); form.submit();
+57 -21
View File
@@ -12,8 +12,10 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js'; import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js'; import * as pathTooltip from '../pathTooltip.js';
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
const favorites = { const favorites = {
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */ /** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
_cache: new Map(), _cache: new Map(),
/** Whether the initial fetch from the server has completed */ /** Whether the initial fetch from the server has completed */
@@ -25,6 +27,10 @@ const favorites = {
return { ...getCsrfHeaders() }; return { ...getCsrfHeaders() };
}, },
/**
* @param {string} id
* @param {string} type
*/
_cacheKey(id, type) { _cacheKey(id, type) {
return `${type}:${id}`; return `${type}:${id}`;
}, },
@@ -33,6 +39,7 @@ const favorites = {
* Replace the entire in-memory cache from an array of FavoriteItemDto * Replace the entire in-memory cache from an array of FavoriteItemDto
* objects (as returned by the batch endpoint). Avoids an extra * objects (as returned by the batch endpoint). Avoids an extra
* GET /api/favorites round-trip. * GET /api/favorites round-trip.
* @param {any[]} items
*/ */
_replaceCacheFromResponse(items) { _replaceCacheFromResponse(items) {
this._cache.clear(); this._cache.clear();
@@ -68,6 +75,7 @@ const favorites = {
return; return;
} }
/** @type {FavoriteItem[]} */
const items = await response.json(); const items = await response.json();
this._cache.clear(); this._cache.clear();
for (const item of items) { for (const item of items) {
@@ -85,6 +93,8 @@ const favorites = {
/** /**
* Synchronous check used by ui.js to paint star icons. * Synchronous check used by ui.js to paint star icons.
* @param {string} id
* @param {string} type
*/ */
isFavorite(id, type) { isFavorite(id, type) {
return this._cache.has(this._cacheKey(id, type)); return this._cache.has(this._cacheKey(id, type));
@@ -92,6 +102,10 @@ const favorites = {
/** /**
* Add an item to favourites (server-first). * Add an item to favourites (server-first).
* @param {string} id
* @param {string} name
* @param {string} type
* @param {string} _parentId
*/ */
async addToFavorites(id, name, type, _parentId) { async addToFavorites(id, name, type, _parentId) {
try { try {
@@ -121,6 +135,8 @@ const favorites = {
/** /**
* Remove an item from favourites (server-first). * Remove an item from favourites (server-first).
* @param {string} id
* @param {string} type
*/ */
async removeFromFavorites(id, type) { async removeFromFavorites(id, type) {
try { try {
@@ -178,31 +194,51 @@ const favorites = {
return; return;
} }
/** @type {FolderItem[]} */
const folders = []; const folders = [];
/** @type {FileItem[]} */
const files = []; const files = [];
for (const item of this._cache.values()) { for (const item of this._cache.values()) {
// TODO: cast objects, but for that need to review user_id vs owner_id...
if (item.item_type === 'folder') { if (item.item_type === 'folder') {
folders.push({ folders.push(
id: item.item_id, // FIXME: better to grab the real values
name: item.item_name || item.item_id, /** @type {FolderItem} */ {
parent_id: item.parent_id || '', id: item.item_id,
modified_at: item.modified_at || item.created_at, name: item.item_name || item.item_id,
path: item.item_path || '' parent_id: item.parent_id || '',
}); modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
category: 'folder',
created_at: item.created_at,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: item.user_id,
is_root: false
}
);
} else { } else {
files.push({ files.push(
id: item.item_id, // FIXME: better to grab the real values
name: item.item_name || item.item_id, /** @type {FileItem} */ {
folder_id: item.parent_id || '', id: item.item_id,
mime_type: item.item_mime_type, name: item.item_name || item.item_id,
icon_class: item.icon_class, folder_id: item.parent_id || '',
icon_special_class: item.icon_special_class, mime_type: item.item_mime_type,
category: item.category, icon_class: item.icon_class,
size: item.item_size || 0, icon_special_class: item.icon_special_class,
size_formatted: item.size_formatted, category: item.category,
modified_at: item.modified_at || item.created_at, size: item.item_size || 0,
path: item.item_path || '' size_formatted: item.size_formatted,
}); modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
owner_id: item.user_id,
created_at: item.created_at,
sort_date: item.created_at
}
);
} }
} }
if (folders.length) ui.renderFolders(folders); if (folders.length) ui.renderFolders(folders);
+164 -33
View File
@@ -6,17 +6,27 @@ import { oxiIcon } from '../../core/icons.js';
import { Modal } from '../../core/modal.js'; import { Modal } from '../../core/modal.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
/** /**
* OxiCloud - Music Library View * OxiCloud - Music Library View
* Playlist management with track listings and audio player * Playlist management with track listings and audio player
*/ */
const musicView = { const musicView = {
/** @type {Playlist[]} */
playlists: [], playlists: [],
/** @type {Playlist | null} */
currentPlaylist: null, currentPlaylist: null,
/** @type {PlaylistItem[]} */
currentTracks: [], currentTracks: [],
loading: false, loading: false,
/** @type {HTMLDivElement | null} */
_container: null, _container: null,
_initialized: false, _initialized: false,
selected: new Set(), selected: new Set(),
@@ -72,11 +82,11 @@ const musicView = {
if (!resp.ok) throw new Error('Failed to load playlists'); if (!resp.ok) throw new Error('Failed to load playlists');
this.playlists = await resp.json(); this.playlists = /** @type {Playlist[]} */ (await resp.json());
this._renderPlaylists(); this._renderPlaylists();
} catch (err) { } catch (err) {
console.error('Music load error:', err); console.error('Music load error:', err);
this._showError(err.message); this._showError(/** @type {Error} */ (err).message);
} finally { } finally {
this.loading = false; this.loading = false;
this._showLoading(false); this._showLoading(false);
@@ -209,7 +219,7 @@ const musicView = {
) )
.join(''); .join('');
listEl.querySelectorAll('.music-playlist-item').forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (listEl.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
const id = item.dataset.id; const id = item.dataset.id;
this._selectPlaylist(id); this._selectPlaylist(id);
@@ -269,6 +279,11 @@ const musicView = {
} }
}, },
/**
*
* @param {string} playlistId
* @returns
*/
async _selectPlaylist(playlistId) { async _selectPlaylist(playlistId) {
const playlist = this.playlists.find((p) => p.id === playlistId); const playlist = this.playlists.find((p) => p.id === playlistId);
if (!playlist) return; if (!playlist) return;
@@ -308,13 +323,18 @@ const musicView = {
togglePublicBtn.classList.toggle('active', playlist.is_public); togglePublicBtn.classList.toggle('active', playlist.is_public);
} }
document.querySelectorAll('.music-playlist-item').forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.classList.toggle('active', item.dataset.id === playlistId); item.classList.toggle('active', item.dataset.id === playlistId);
}); });
await this._loadPlaylistTracks(playlistId); await this._loadPlaylistTracks(playlistId);
}, },
/**
*
* @param {string} playlistId
* @returns
*/
async _loadPlaylistTracks(playlistId) { async _loadPlaylistTracks(playlistId) {
const trackListEl = document.getElementById('music-track-list'); const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return; if (!trackListEl) return;
@@ -333,7 +353,7 @@ const musicView = {
this._renderTracks(); this._renderTracks();
} catch (err) { } catch (err) {
console.error('Track load error:', err); console.error('Track load error:', err);
trackListEl.innerHTML = `<div class="music-error">${err.message}</div>`; trackListEl.innerHTML = `<div class="music-error">${/** @type {Error} */ (err).message}</div>`;
} }
}, },
@@ -385,7 +405,7 @@ const musicView = {
) )
.join('')} .join('')}
`; `;
trackListEl.querySelectorAll('.music-track').forEach((row) => { /** @type {NodeListOf<HTMLDivElement>} */ (trackListEl.querySelectorAll('.music-track')).forEach((row) => {
row.addEventListener('click', () => { row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx, 10); const idx = parseInt(row.dataset.idx, 10);
// Toggle selection // Toggle selection
@@ -449,6 +469,11 @@ const musicView = {
}); });
}, },
/**
*
* @param {number} idx
* @returns
*/
_playTrack(idx) { _playTrack(idx) {
if (!this.currentTracks[idx]) return; if (!this.currentTracks[idx]) return;
@@ -487,8 +512,12 @@ const musicView = {
this._createPlaylist(name.trim()); this._createPlaylist(name.trim());
}, },
/**
*
* @param {String} name
*/
async _createPlaylist(name) { async _createPlaylist(name) {
const createBtn = document.getElementById('music-create-playlist-btn'); const createBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-create-playlist-btn'));
if (createBtn) createBtn.disabled = true; if (createBtn) createBtn.disabled = true;
try { try {
const resp = await fetch('/api/playlists', { const resp = await fetch('/api/playlists', {
@@ -519,7 +548,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} finally { } finally {
@@ -542,7 +571,7 @@ const musicView = {
}); });
if (!confirmed) return; if (!confirmed) return;
const deleteBtn = document.getElementById('music-delete-playlist-btn'); const deleteBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-delete-playlist-btn'));
if (deleteBtn) deleteBtn.disabled = true; if (deleteBtn) deleteBtn.disabled = true;
try { try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, { const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
@@ -568,7 +597,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} finally { } finally {
@@ -576,6 +605,11 @@ const musicView = {
} }
}, },
/**
*
* @param {number|null} secs
* @returns
*/
_formatDuration(secs) { _formatDuration(secs) {
if (!secs) return '-'; if (!secs) return '-';
const mins = Math.floor(secs / 60); const mins = Math.floor(secs / 60);
@@ -583,11 +617,20 @@ const musicView = {
return `${mins}:${s.toString().padStart(2, '0')}`; return `${mins}:${s.toString().padStart(2, '0')}`;
}, },
/**
*
* @param {string|null} str
* @returns
*/
_escapeHtml(str) { _escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}, },
/**
*
* @param {boolean} show
*/
_showLoading(show) { _showLoading(show) {
const existing = this._container?.querySelector('.music-loading'); const existing = this._container?.querySelector('.music-loading');
if (show && !existing) { if (show && !existing) {
@@ -600,6 +643,11 @@ const musicView = {
} }
}, },
/**
*
* @param {string} message
* @returns
*/
_showError(message) { _showError(message) {
if (!this._container) return; if (!this._container) return;
this._container.innerHTML = ` this._container.innerHTML = `
@@ -647,7 +695,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -690,7 +738,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -731,8 +779,8 @@ const musicView = {
requestAnimationFrame(() => overlay.classList.add('active')); requestAnimationFrame(() => overlay.classList.add('active'));
const listEl = document.getElementById('music-picker-list'); const listEl = document.getElementById('music-picker-list');
const queryInput = document.getElementById('music-picker-query'); const queryInput = /** @type {HTMLInputElement} */ (document.getElementById('music-picker-query'));
const addBtn = document.getElementById('music-picker-add-btn'); const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-picker-add-btn'));
const countEl = document.getElementById('music-picker-count'); const countEl = document.getElementById('music-picker-count');
const selectedIds = new Set(); const selectedIds = new Set();
@@ -765,6 +813,11 @@ const musicView = {
} }
}; };
/**
*
* @param {FileItem[]} files
* @returns
*/
const renderFiles = (files) => { const renderFiles = (files) => {
if (files.length === 0) { if (files.length === 0) {
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${i18n.t('music.no_audio_files')}</div>`; listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${i18n.t('music.no_audio_files')}</div>`;
@@ -798,6 +851,7 @@ const musicView = {
}; };
// ── Debounced search ── // ── Debounced search ──
/** @type {ReturnType<typeof setTimeout> | null} */
let searchTimer = null; let searchTimer = null;
queryInput.addEventListener('input', () => { queryInput.addEventListener('input', () => {
clearTimeout(searchTimer); clearTimeout(searchTimer);
@@ -857,6 +911,12 @@ const musicView = {
fetchAudioFiles(); fetchAudioFiles();
}, },
/**
*
* @param {string} _trackId
* @param {string} fileId
* @returns
*/
async _removeTrackFromPlaylist(_trackId, fileId) { async _removeTrackFromPlaylist(_trackId, fileId) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
@@ -892,12 +952,18 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
}, },
/**
*
* @param {number} fromIdx
* @param {number} toIdx
* @returns
*/
async _reorderTrack(fromIdx, toIdx) { async _reorderTrack(fromIdx, toIdx) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
@@ -923,7 +989,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
await this._loadPlaylistTracks(this.currentPlaylist.id); await this._loadPlaylistTracks(this.currentPlaylist.id);
@@ -967,8 +1033,8 @@ const musicView = {
}); });
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => { dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
const userInput = dialog.querySelector('#music-share-user-input'); const userInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-user-input'));
const writeInput = dialog.querySelector('#music-share-write-input'); const writeInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-write-input'));
const userId = userInput.value.trim(); const userId = userInput.value.trim();
if (!userId) return; if (!userId) return;
@@ -997,7 +1063,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1006,6 +1072,11 @@ const musicView = {
this._loadSharesList(dialog); this._loadSharesList(dialog);
}, },
/**
*
* @param {HTMLDivElement} dialog
* @returns
*/
async _loadSharesList(dialog) { async _loadSharesList(dialog) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
const body = dialog.querySelector('.music-shares-body'); const body = dialog.querySelector('.music-shares-body');
@@ -1019,6 +1090,7 @@ const musicView = {
headers: this._headers() headers: this._headers()
}); });
if (!resp.ok) throw new Error('Failed to load shares'); if (!resp.ok) throw new Error('Failed to load shares');
/** @type {Musicshare[]} */
const shares = await resp.json(); const shares = await resp.json();
if (shares.length === 0) { if (shares.length === 0) {
@@ -1040,16 +1112,22 @@ const musicView = {
body.querySelectorAll('.music-share-remove-btn').forEach((btn) => { body.querySelectorAll('.music-share-remove-btn').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const item = btn.closest('.music-share-item'); const item = /** @type {HTMLDivElement} */ (btn.closest('.music-share-item'));
const userId = item.dataset.userId; const userId = item.dataset.userId;
await this._removeShare(userId, dialog); await this._removeShare(userId, dialog);
}); });
}); });
} catch (err) { } catch (err) {
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(err.message)}</p>`; body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(/** @type {Error} */ (err).message)}</p>`;
} }
}, },
/**
*
* @param {string} userId
* @param {HTMLDivElement} dialog
* @returns
*/
async _removeShare(userId, dialog) { async _removeShare(userId, dialog) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
@@ -1067,7 +1145,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1115,7 +1193,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1183,7 +1261,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1198,10 +1276,15 @@ const musicView = {
* Handles audio playback, queue, and controls * Handles audio playback, queue, and controls
*/ */
const musicPlayer = { const musicPlayer = {
/** @type {HTMLAudioElement | null} */
audio: null, audio: null,
/** @type {PlaylistItem[]} */
queue: [], queue: [],
currentIndex: -1, currentIndex: -1,
/** @type {PlaylistItem|null} */
currentTrack: null, currentTrack: null,
isPlaying: false, isPlaying: false,
volume: 0.7, volume: 0.7,
isMuted: false, isMuted: false,
@@ -1306,7 +1389,7 @@ const musicPlayer = {
const shuffleBtn = document.getElementById('player-shuffle-btn'); const shuffleBtn = document.getElementById('player-shuffle-btn');
const repeatBtn = document.getElementById('player-repeat-btn'); const repeatBtn = document.getElementById('player-repeat-btn');
const progressBar = document.getElementById('player-progress-bar'); const progressBar = document.getElementById('player-progress-bar');
const volumeInput = document.getElementById('player-volume-input'); const volumeInput = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
const volBtn = document.getElementById('player-vol-btn'); const volBtn = document.getElementById('player-vol-btn');
const playlistBtn = document.getElementById('player-playlist-btn'); const playlistBtn = document.getElementById('player-playlist-btn');
const closeQueueBtn = document.getElementById('player-close-queue-btn'); const closeQueueBtn = document.getElementById('player-close-queue-btn');
@@ -1338,7 +1421,8 @@ const musicPlayer = {
if (volumeInput) { if (volumeInput) {
volumeInput.addEventListener('input', (e) => { volumeInput.addEventListener('input', (e) => {
this.setVolume(e.target.value / 100); const target = /** @type {HTMLInputElement} */ (e.target);
this.setVolume(parseFloat(target.value) / 100);
}); });
} }
@@ -1381,12 +1465,22 @@ const musicPlayer = {
document.body.classList.remove('music-player-active'); document.body.classList.remove('music-player-active');
}, },
/**
*
* @param {PlaylistItem[]} tracks
* @param {string} playlistName
*/
setQueue(tracks, playlistName = '') { setQueue(tracks, playlistName = '') {
this.queue = [...tracks]; this.queue = [...tracks];
this.playlistName = playlistName; this.playlistName = playlistName;
this._updateQueueUI(); this._updateQueueUI();
}, },
/**
*
* @param {number} index
* @returns
*/
playTrack(index) { playTrack(index) {
if (index < 0 || index >= this.queue.length) return; if (index < 0 || index >= this.queue.length) return;
@@ -1488,15 +1582,19 @@ const musicPlayer = {
} }
}, },
/**
*
* @param {number} vol
*/
setVolume(vol) { setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol)); this.volume = Math.max(0, Math.min(1, vol));
this.audio.volume = this.volume; this.audio.volume = this.volume;
this.isMuted = this.volume === 0; this.isMuted = this.volume === 0;
this._updateVolumeIcon(); this._updateVolumeIcon();
const input = document.getElementById('player-volume-input'); const input = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
if (input) { if (input) {
input.value = this.volume * 100; input.value = String(this.volume * 100);
} }
}, },
@@ -1522,6 +1620,11 @@ const musicPlayer = {
btn.querySelector('i').className = `fas ${icon}`; btn.querySelector('i').className = `fas ${icon}`;
}, },
/**
*
* @param {PointerEvent} e
* @returns
*/
_seek(e) { _seek(e) {
const bar = document.getElementById('player-progress-bar'); const bar = document.getElementById('player-progress-bar');
if (!bar) return; if (!bar) return;
@@ -1596,6 +1699,10 @@ const musicPlayer = {
this._updateUI(); this._updateUI();
}, },
/**
*
* @param {ErrorEvent} e
*/
_onError(e) { _onError(e) {
console.error('Audio error:', e); console.error('Audio error:', e);
this.isPlaying = false; this.isPlaying = false;
@@ -1624,7 +1731,8 @@ const musicPlayer = {
if (oxiIcon) { if (oxiIcon) {
icon.outerHTML = oxiIcon(iconName, extraClass); icon.outerHTML = oxiIcon(iconName, extraClass);
} else { } else {
icon.className = `fas fa-${iconName} ${extraClass}`; icon.classList.remove(...icon.classList);
icon.classList.add('fas', `fa-${iconName}`, `${extraClass}`);
} }
} }
} }
@@ -1640,7 +1748,7 @@ const musicPlayer = {
} }
if (musicView.currentTracks.length > 0) { if (musicView.currentTracks.length > 0) {
document.querySelectorAll('.music-track').forEach((row) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-track')).forEach((row) => {
const idx = parseInt(row.dataset.idx, 10); const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying); row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
@@ -1710,15 +1818,16 @@ const musicPlayer = {
) )
.join(''); .join('');
queueList.querySelectorAll('.player-queue-item').forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (queueList.querySelectorAll('.player-queue-item')).forEach((item) => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
if (e.target.closest('.queue-item-remove')) return; const target = /** @type {Element} */ (e.target);
if (target.closest('.queue-item-remove')) return;
const idx = parseInt(item.dataset.idx, 10); const idx = parseInt(item.dataset.idx, 10);
this.playTrack(idx); this.playTrack(idx);
}); });
}); });
queueList.querySelectorAll('.queue-item-remove').forEach((btn) => { /** @type {NodeListOf<HTMLButtonElement>} */ (queueList.querySelectorAll('.queue-item-remove')).forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const idx = parseInt(btn.dataset.idx, 10); const idx = parseInt(btn.dataset.idx, 10);
@@ -1727,6 +1836,10 @@ const musicPlayer = {
}); });
}, },
/**
*
* @param {number} idx
*/
_removeFromQueue(idx) { _removeFromQueue(idx) {
if (idx === this.currentIndex) { if (idx === this.currentIndex) {
if (this.queue.length === 1) { if (this.queue.length === 1) {
@@ -1754,6 +1867,10 @@ const musicPlayer = {
this._updateUI(); this._updateUI();
}, },
/**
*
* @param {boolean|undefined} [show]
*/
_toggleQueue(show) { _toggleQueue(show) {
const queue = document.getElementById('player-queue'); const queue = document.getElementById('player-queue');
if (queue) { if (queue) {
@@ -1765,6 +1882,11 @@ const musicPlayer = {
} }
}, },
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatTime(secs) { _formatTime(secs) {
if (!secs || Number.isNaN(secs)) return '0:00'; if (!secs || Number.isNaN(secs)) return '0:00';
const mins = Math.floor(secs / 60); const mins = Math.floor(secs / 60);
@@ -1772,10 +1894,19 @@ const musicPlayer = {
return `${mins}:${s.toString().padStart(2, '0')}`; return `${mins}:${s.toString().padStart(2, '0')}`;
}, },
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatDuration(secs) { _formatDuration(secs) {
return this._formatTime(secs); return this._formatTime(secs);
}, },
/**
* @param {string|null} str
* @returns {String}
*/
_escapeHtml(str) { _escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+48 -15
View File
@@ -8,10 +8,14 @@ import { i18n } from '../../core/i18n.js';
import { thumbnail } from '../thumbnail.js'; import { thumbnail } from '../thumbnail.js';
import { photosLightbox } from './photosLightbox.js'; import { photosLightbox } from './photosLightbox.js';
/** @import {FileInfo} from '../../core/types.js' */ /** @import {FileItem} from '../../core/types.js' */
/**
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
*/
const photosView = { const photosView = {
/** @type {Array} All loaded photo items */ /** @type {Array<FileItem>} All loaded photo items */
items: [], items: [],
/** @type {string|null} Cursor for next page */ /** @type {string|null} Cursor for next page */
nextCursor: null, nextCursor: null,
@@ -27,7 +31,7 @@ const photosView = {
_container: null, _container: null,
/** @type {boolean} */ /** @type {boolean} */
_initialized: false, _initialized: false,
/** @type {'daily'|'monthly'|'yearly'} */ /** @type {PhotoModeEnum} */
groupMode: 'monthly', groupMode: 'monthly',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */ /** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(), _videoThumbCache: new Map(),
@@ -84,6 +88,11 @@ const photosView = {
}, },
/** Switch grouping mode */ /** Switch grouping mode */
/**
*
* @param {PhotoModeEnum} mode
* @returns
*/
setGroupMode(mode) { setGroupMode(mode) {
if (this.groupMode === mode) return; if (this.groupMode === mode) return;
this.groupMode = mode; this.groupMode = mode;
@@ -112,6 +121,7 @@ const photosView = {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
/** @type {FileItem[]} */
const data = await res.json(); const data = await res.json();
if (!data || data.length === 0) { if (!data || data.length === 0) {
@@ -178,7 +188,9 @@ const photosView = {
/** Append-only render for infinite scroll — inserts only the items /** Append-only render for infinite scroll — inserts only the items
* from this.items[startIndex..] without destroying existing DOM. * from this.items[startIndex..] without destroying existing DOM.
* Complexity: O(batch) instead of O(total_items). */ * Complexity: O(batch) instead of O(total_items).
* @param {number} startIndex
*/
_appendBatch(startIndex) { _appendBatch(startIndex) {
if (!this._container) return; if (!this._container) return;
this._destroyObserver(); this._destroyObserver();
@@ -227,7 +239,10 @@ const photosView = {
this._setupVideoThumbnails(startIndex); this._setupVideoThumbnails(startIndex);
}, },
/** Generate HTML for a single photo/video tile */ /**
* Generate HTML for a single photo/video tile
* @param {FileItem} file
*/
_renderTile(file) { _renderTile(file) {
const isVideo = file.mime_type?.startsWith('video/'); const isVideo = file.mime_type?.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : ''; const selected = this.selected.has(file.id) ? ' selected' : '';
@@ -266,7 +281,7 @@ const photosView = {
/** @param {number} [startIndex=0] When > 0, only process video tiles /** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */ * for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) { _setupVideoThumbnails(startIndex = 0) {
const tiles = /** @type {NodeListOf<HTMLDivElement> */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]')); const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null; const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
if (!tiles) return; if (!tiles) return;
@@ -290,11 +305,15 @@ const photosView = {
} }
}, },
/** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */ /**
* Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate().
* @param {HTMLDivElement} tile
* @param {HTMLImageElement} img
*/
async _generateVideoThumbnail(tile, img) { async _generateVideoThumbnail(tile, img) {
const fileId = tile.dataset.id; const fileId = tile.dataset.id;
// TODO: remove this HACK, this is not evolutive... // TODO: remove this HACK, this is not evolutive...
const file = /** @type {FileInfo} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime }); const file = /** @type {FileItem} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
try { try {
await thumbnail.queueGenerate(file, null, (previewDataUrl) => { await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
@@ -335,7 +354,10 @@ const photosView = {
</div>`; </div>`;
}, },
/** Group items by the current groupMode */ /**
* Group items by the current groupMode
* @param {FileItem[]} items
*/
_groupItems(items) { _groupItems(items) {
const map = new Map(); const map = new Map();
for (const item of items) { for (const item of items) {
@@ -363,20 +385,24 @@ const photosView = {
return map; return map;
}, },
/** Handle click on photo tile or toolbar */ /**
* Handle click on photo tile or toolbar
* @param {MouseEvent} e
*/
_handleClick(e) { _handleClick(e) {
// Handle group mode toggle // Handle group mode toggle
const modeBtn = e.target.closest('[data-group-mode]'); const target = /** @type {Element} */ (e.target);
const modeBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-group-mode]'));
if (modeBtn) { if (modeBtn) {
this.setGroupMode(modeBtn.dataset.groupMode); this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
return; return;
} }
const tile = e.target.closest('.photo-tile'); const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return; if (!tile) return;
const id = tile.dataset.id; const id = tile.dataset.id;
const check = e.target.closest('.photo-check'); const check = target.closest('.photo-check');
// If clicking checkbox or in selection mode, toggle select // If clicking checkbox or in selection mode, toggle select
if (check || this.selected.size > 0) { if (check || this.selected.size > 0) {
@@ -391,7 +417,11 @@ const photosView = {
} }
}, },
/** Toggle selection of an item */ /**
* Toggle selection of an item
* @param {string} id
* @param {HTMLDivElement} tile
*/
_toggleSelect(id, tile) { _toggleSelect(id, tile) {
if (this.selected.has(id)) { if (this.selected.has(id)) {
this.selected.delete(id); this.selected.delete(id);
@@ -483,6 +513,7 @@ const photosView = {
if (bar) bar.style.display = 'none'; if (bar) bar.style.display = 'none';
}, },
/** @param {boolean} show */
_showLoading(show) { _showLoading(show) {
if (!this._container) return; if (!this._container) return;
let loader = this._container.querySelector('.photos-loading'); let loader = this._container.querySelector('.photos-loading');
@@ -503,12 +534,14 @@ const photosView = {
} }
}, },
/** @param {any} s */
_escHtml(s) { _escHtml(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
}, },
/** @param {any} s */
_escAttr(s) { _escAttr(s) {
return String(s || '') return String(s || '')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
+39 -23
View File
@@ -6,8 +6,11 @@
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { favorites } from '../library/favorites.js'; import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
export const photosLightbox = { export const photosLightbox = {
/** @type {Array} Items array reference */ /** @type {Array<FileItem>} Items array reference */
items: [], items: [],
/** @type {number} Current index */ /** @type {number} Current index */
index: -1, index: -1,
@@ -15,14 +18,14 @@ export const photosLightbox = {
_overlay: null, _overlay: null,
/** @type {string|null} Current blob URL to revoke */ /** @type {string|null} Current blob URL to revoke */
_blobUrl: null, _blobUrl: null,
/** @type {Function|null} */ /** @type {(ev: KeyboardEvent) => any|null} */
_keyHandler: null, _keyHandler: null,
/** @type {Object|null} Reference to photosView, set after both modules load */ /** @type {PhotosView|null} Reference to photosView, set after both modules load */
_photosView: null, _photosView: null,
/** /**
* Register the photosView reference (called from photos.js to avoid circular imports). * Register the photosView reference (called from photos.js to avoid circular imports).
* @param {Object} pv * @param {any} pv
*/ */
setPhotosView(pv) { setPhotosView(pv) {
this._photosView = pv; this._photosView = pv;
@@ -33,7 +36,11 @@ export const photosLightbox = {
return getCsrfHeaders(); return getCsrfHeaders();
}, },
/** Open lightbox at given index */ /**
* Open lightbox at given index
* @param {FileItem[]} items
* @param {number} index
*/
open(items, index) { open(items, index) {
this.items = items; this.items = items;
this.index = index; this.index = index;
@@ -99,21 +106,21 @@ export const photosLightbox = {
this._overlay = el; this._overlay = el;
// Event listeners // Event listeners
el.querySelector('.lightbox-close').onclick = () => this.close(); /** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
el.querySelector('.lightbox-prev').onclick = () => this.prev(); /** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
el.querySelector('.lightbox-next').onclick = () => this.next(); /** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
// Click backdrop to close // Click backdrop to close
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
if (e.target === el || e.target.classList.contains('lightbox-content')) { if (e.target === el || /** @type {HTMLElement} */ (e.target).classList.contains('lightbox-content')) {
this.close(); this.close();
} }
}); });
// Toolbar actions // Toolbar actions
el.querySelector('.lb-download').onclick = () => this._download(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
el.querySelector('.lb-delete').onclick = () => this._delete(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
// Animate in // Animate in
requestAnimationFrame(() => el.classList.add('active')); requestAnimationFrame(() => el.classList.add('active'));
@@ -144,8 +151,8 @@ export const photosLightbox = {
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`; meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
// Update nav button visibility // Update nav button visibility
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden'; /** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden'; /** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
// Load content // Load content
this._revokeBlob(); this._revokeBlob();
@@ -176,7 +183,13 @@ export const photosLightbox = {
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || ''); this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
}, },
/** Load EXIF metadata for info bar */ /**
* Load EXIF metadata for info bar
* @param {string} fileId
* @param {Element} metaEl
* @param {string} dateStr
* @param {string} sizeStr
*/
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) { async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
try { try {
const res = await fetch(`/api/files/${fileId}/metadata`, { const res = await fetch(`/api/files/${fileId}/metadata`, {
@@ -184,16 +197,18 @@ export const photosLightbox = {
headers: this._headers() headers: this._headers()
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const metadata = /** @type {FileMetadata} */ (await res.json());
const parts = [dateStr]; const parts = [dateStr];
if (sizeStr) parts.push(sizeStr); if (sizeStr) parts.push(sizeStr);
if (data.camera_make || data.camera_model) { if (metadata.camera_make || metadata.camera_model) {
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' ')); parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
} }
if (data.width && data.height) { if (metadata.width && metadata.height) {
parts.push(`${data.width}×${data.height}`); parts.push(`${metadata.width}×${metadata.height}`);
} }
metaEl.textContent = parts.join(' · '); metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
} }
} catch (_err) { } catch (_err) {
// Non-critical, keep existing meta // Non-critical, keep existing meta
@@ -220,7 +235,7 @@ export const photosLightbox = {
await fetch(`/api/favorites/file/${item.id}`, { await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: this._headers(true) headers: this._headers()
}); });
const btn = this._overlay.querySelector('.lb-favorite'); const btn = this._overlay.querySelector('.lb-favorite');
if (btn) { if (btn) {
@@ -254,11 +269,11 @@ export const photosLightbox = {
this.items.splice(this.index, 1); this.items.splice(this.index, 1);
if (this.items.length === 0) { if (this.items.length === 0) {
this.close(); this.close();
if (this._photosView) this._photosView._render(); if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
} else { } else {
if (this.index >= this.items.length) this.index = this.items.length - 1; if (this.index >= this.items.length) this.index = this.items.length - 1;
this._show(); this._show();
if (this._photosView) this._photosView._render(); if (this._photosView) this._photosView._renderFull();
} }
} catch (err) { } catch (err) {
console.error('Delete failed:', err); console.error('Delete failed:', err);
@@ -289,6 +304,7 @@ export const photosLightbox = {
} }
}, },
/** @param {any} s */
_escAttr(s) { _escAttr(s) {
return String(s || '') return String(s || '')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
+22 -4
View File
@@ -12,6 +12,8 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js'; import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js'; import * as pathTooltip from '../pathTooltip.js';
/** @import {FileItem, FolderItem, ItemTypeEnum} from '../../core/types.js' */
const recent = { const recent = {
/** Maximum items to request from the server */ /** Maximum items to request from the server */
MAX_RECENT_FILES: 20, MAX_RECENT_FILES: 20,
@@ -38,8 +40,9 @@ const recent = {
*/ */
setupEventListeners() { setupEventListeners() {
document.addEventListener('file-accessed', (event) => { document.addEventListener('file-accessed', (event) => {
if (event.detail?.file) { const e = /** @type {CustomEvent} */ (event);
const file = event.detail.file; if (e.detail?.file) {
const file = e.detail.file;
const itemType = file.item_type || 'file'; const itemType = file.item_type || 'file';
this._recordAccess(file.id, itemType); this._recordAccess(file.id, itemType);
} }
@@ -48,6 +51,8 @@ const recent = {
/** /**
* Record an access event on the server. * Record an access event on the server.
* @param {string} itemId
* @param {ItemTypeEnum} itemType
*/ */
async _recordAccess(itemId, itemType) { async _recordAccess(itemId, itemType) {
try { try {
@@ -120,8 +125,12 @@ const recent = {
`); `);
} }
/** @type {FolderItem[]} */
const folders = []; const folders = [];
/** @type {FileItem[]} */
const files = []; const files = [];
for (const item of recentItems) { for (const item of recentItems) {
const isFolder = item.item_type === 'folder'; const isFolder = item.item_type === 'folder';
if (isFolder) { if (isFolder) {
@@ -130,7 +139,13 @@ const recent = {
name: item.item_name || item.item_id, name: item.item_name || item.item_id,
parent_id: item.parent_id || '', parent_id: item.parent_id || '',
modified_at: item.accessed_at, modified_at: item.accessed_at,
path: item.item_path || '' path: item.item_path || '',
category: 'folder',
created_at: item.created_at,
icon_class: '',
icon_special_class: '',
owner_id: '',
is_root: false
}); });
} else { } else {
files.push({ files.push({
@@ -144,7 +159,10 @@ const recent = {
size: item.item_size || 0, size: item.item_size || 0,
size_formatted: item.size_formatted, size_formatted: item.size_formatted,
modified_at: item.accessed_at, modified_at: item.accessed_at,
path: item.item_path || '' path: item.item_path || '',
owner_id: '',
created_at: item.created_at,
sort_date: item.created_at
}); });
} }
} }
+13 -2
View File
@@ -38,7 +38,13 @@ function _onLeave() {
_tooltip?.classList.add('hidden'); _tooltip?.classList.add('hidden');
} }
/** @type {WeakMap<HTMLElement, {enter: Function, leave: Function}>} */ /**
* @typedef {Object} EnterLeaveF
* @property {(e: MouseEvent) => void} enter
* @property {(e: MouseEvent) => void} leave
*
/** @type {WeakMap<HTMLElement, EnterLeaveF>} */
const _listeners = new WeakMap(); const _listeners = new WeakMap();
/** /**
@@ -49,10 +55,15 @@ function init(container) {
const items = container.querySelectorAll('.file-item[data-path]'); const items = container.querySelectorAll('.file-item[data-path]');
items.forEach((item) => { items.forEach((item) => {
const el = /** @type {HTMLElement} */ (item); const el = /** @type {HTMLElement} */ (item);
/** @type {(e: MouseEvent) => void} */
const enter = (e) => _onEnter(e); const enter = (e) => _onEnter(e);
const leave = () => _onLeave();
el.addEventListener('mouseenter', enter); el.addEventListener('mouseenter', enter);
/** @type {(e: MouseEvent) => void} */
const leave = (_e) => _onLeave();
el.addEventListener('mouseleave', leave); el.addEventListener('mouseleave', leave);
_listeners.set(el, { enter, leave }); _listeners.set(el, { enter, leave });
}); });
} }
+15 -8
View File
@@ -9,6 +9,10 @@ import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateTime } from '../../core/formatters.js'; import { formatDateTime } from '../../core/formatters.js';
/**
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
*/
const fileSharing = { const fileSharing = {
/** Auth header helper — tokens are in HttpOnly cookies now */ /** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = true) { _headers(json = true) {
@@ -21,16 +25,17 @@ const fileSharing = {
* Create a shared link via backend API * Create a shared link via backend API
* @param {string} itemId - ID of the file or folder * @param {string} itemId - ID of the file or folder
* @param {string} itemType - 'file' or 'folder' * @param {string} itemType - 'file' or 'folder'
* @param {Object} options - { name, password, expirationDate, permissions } * @param {CreateShare} [options] -
* @returns {Promise<Object>} ShareDto from backend * @returns {Promise<Object>} ShareDto from backend
*/ */
async createSharedLink(itemId, itemType, options = {}) { // FIXME unused ?? duplicate with createSharedLink() from contextMenu
async createSharedLink(itemId, itemType, options) {
const body = { const body = {
item_id: itemId, item_id: itemId,
item_name: options.name || null, item_name: options.item_name || null,
item_type: itemType, item_type: itemType,
password: options.password || null, password: options.password || null,
expires_at: options.expirationDate ? Math.floor(new Date(options.expirationDate).getTime() / 1000) : null, expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null,
permissions: options.permissions || { permissions: options.permissions || {
read: true, read: true,
write: false, write: false,
@@ -54,7 +59,7 @@ const fileSharing = {
/** /**
* Get all shared links for the current user * Get all shared links for the current user
* @returns {Promise<Array>} Array of ShareDto * @returns {Promise<ShareItem[]>} Array of ShareDto
*/ */
async getSharedLinks() { async getSharedLinks() {
try { try {
@@ -62,7 +67,7 @@ const fileSharing = {
headers: this._headers(false) headers: this._headers(false)
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = /** @type {ShareItem[]} */ await res.json();
return data.items || []; return data.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching shared links:', error); console.error('Error fetching shared links:', error);
@@ -74,7 +79,7 @@ const fileSharing = {
* Get shared links for a specific item (server-side filtered) * Get shared links for a specific item (server-side filtered)
* @param {string} itemId * @param {string} itemId
* @param {string} itemType - 'file' or 'folder' * @param {string} itemType - 'file' or 'folder'
* @returns {Promise<Array>} Shares for this item * @returns {Promise<ShareItem[]>} Shares for this item
*/ */
async getSharedLinksForItem(itemId, itemType) { async getSharedLinksForItem(itemId, itemType) {
try { try {
@@ -96,6 +101,8 @@ const fileSharing = {
/** /**
* Check if an item has any shared links * Check if an item has any shared links
* @param {string} itemId
* @param {string} itemType
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async hasSharedLinks(itemId, itemType) { async hasSharedLinks(itemId, itemType) {
@@ -106,7 +113,7 @@ const fileSharing = {
/** /**
* Update a shared link * Update a shared link
* @param {string} shareId * @param {string} shareId
* @param {Object} updateData - { permissions, password, expires_at } * @param {UpdateShare} updateData - { permissions, password, expires_at }
* @returns {Promise<Object>} Updated ShareDto * @returns {Promise<Object>} Updated ShareDto
*/ */
async updateSharedLink(shareId, updateData) { async updateSharedLink(shareId, updateData) {
+15 -10
View File
@@ -1,8 +1,11 @@
import { getCsrfHeaders } from '../core/csrf.js'; import { getCsrfHeaders } from '../core/csrf.js';
/** @import {FileInfo} from '../core/types.js' */ /** @import {FileItem} from '../core/types.js' */
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */ /**
* use any type so tsc will not scan library
* @type {any}
*/
let _pdfjsLib = null; let _pdfjsLib = null;
// TODO: do we need to add a max concurrncy ? // TODO: do we need to add a max concurrncy ?
@@ -10,11 +13,13 @@ let _pdfjsLib = null;
/** /**
* Lazy-loads pdf.min.mjs on first use via dynamic import so it is never * Lazy-loads pdf.min.mjs on first use via dynamic import so it is never
* bundled into the IIFE (it uses top-level await which breaks IIFE wrapping). * bundled into the IIFE (it uses top-level await which breaks IIFE wrapping).
* @returns {Promise<typeof import('../vendors/pdf.min.d.ts')>} * @returns {Promise<any>}
*/ */
async function getPdfjsLib() { async function getPdfjsLib() {
if (_pdfjsLib) return _pdfjsLib; if (_pdfjsLib) return _pdfjsLib;
_pdfjsLib = await import('/js/vendors/pdf.min.mjs'); // IMPORTANT: this hack (const lib=...) so tsc will not load vendors library
const lib = '../vendors/pdf.min.mjs';
_pdfjsLib = /** @type {any} */ (await import(lib));
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs'; _pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
return _pdfjsLib; return _pdfjsLib;
} }
@@ -23,7 +28,7 @@ export const thumbnail = {
SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//], SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//],
/** /**
* *
* @param {Object} file * @param {FileItem} file
* @returns {boolean} * @returns {boolean}
*/ */
canHandle(file) { canHandle(file) {
@@ -109,7 +114,7 @@ export const thumbnail = {
/** /**
* *
* @param {FileInfo} file * @param {FileItem} file
* @param {string} source * @param {string} source
* @returns {Promise<ImageBitmap>} * @returns {Promise<ImageBitmap>}
* *
@@ -163,7 +168,7 @@ export const thumbnail = {
/** /**
* generateThumbnail and update image * generateThumbnail and update image
* *
* @param {Object} file the source of the image * @param {FileItem} file the source of the image
* @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated * @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated * @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated
* *
@@ -203,7 +208,7 @@ export const thumbnail = {
MAX_CONCURRENT: 3, MAX_CONCURRENT: 3,
_activeGenerates: 0, _activeGenerates: 0,
/** @type {Array<() => void>} */ /** @type {Array<(resolve: any) => void>} */
_generateQueue: [], _generateQueue: [],
/** /**
@@ -211,7 +216,7 @@ export const thumbnail = {
* At most MAX_CONCURRENT generations run simultaneously; excess calls are * At most MAX_CONCURRENT generations run simultaneously; excess calls are
* queued and resume automatically as slots free up. * queued and resume automatically as slots free up.
* *
* @param {FileInfo} file * @param {FileItem} file
* @param {((dataURL: string) => void) | null} [onIconGenerated] * @param {((dataURL: string) => void) | null} [onIconGenerated]
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] * @param {((dataURL: string) => void) | null} [onPreviewGenerated]
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -224,7 +229,7 @@ export const thumbnail = {
try { try {
await this._generate(file, onIconGenerated, onPreviewGenerated); await this._generate(file, onIconGenerated, onPreviewGenerated);
} catch (err) { } catch (err) {
if (err instanceof Event) { if (err instanceof Event && 'error' in err.target) {
console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error); console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error);
} else if (err instanceof Error) { } else if (err instanceof Error) {
console.warn(`generation of thumbnail for ${file.name} failed: `, err.message); console.warn(`generation of thumbnail for ${file.name} failed: `, err.message);
+4
View File
@@ -0,0 +1,4 @@
declare module '*pdf.min.mjs' {
const pdfjsLib: any;
export = pdfjsLib;
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.mjs' {
const value: any;
export default value;
}
+169 -109
View File
@@ -3,13 +3,20 @@ import { escapeHtml } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { oxiIconsInit } from '../../core/icons.js'; import { oxiIconsInit } from '../../core/icons.js';
/**
* @import {RoleEnum} from '../../core/types.js'
*/
const API = '/api'; const API = '/api';
let currentAdminId = ''; let currentAdminId = '';
let usersPage = 0; let usersPage = 0;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
let totalUsers = 0; let totalUsers = 0;
/** Escape a string for safe embedding inside a JS string literal within an HTML attribute. */ /**
* Escape a string for safe embedding inside a JS string literal within an HTML attribute.
* @param {string} s
*/
function _escJs(s) { function _escJs(s) {
if (typeof s !== 'string') return ''; if (typeof s !== 'string') return '';
return s.replace(/[^\w .-]/g, (c) => { return s.replace(/[^\w .-]/g, (c) => {
@@ -17,6 +24,7 @@ function _escJs(s) {
}); });
} }
/** @param {string} id */
function hideElement(id) { function hideElement(id) {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return; if (!element) return;
@@ -24,6 +32,10 @@ function hideElement(id) {
element.classList.add('hidden'); element.classList.add('hidden');
} }
/**
* @param {string} id
* @param {string} [mode]
*/
function showElement(id, mode = 'block') { function showElement(id, mode = 'block') {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return; if (!element) return;
@@ -39,6 +51,7 @@ function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
} }
/** @param {number} bytes */
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024, const k = 1024,
@@ -47,11 +60,12 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
} }
/** @param {string|null} dateStr */
function timeAgo(dateStr) { function timeAgo(dateStr) {
if (!dateStr) return i18n.t('admin.never'); if (!dateStr) return i18n.t('admin.never');
const d = new Date(dateStr); const d = new Date(dateStr);
const now = new Date(); const now = new Date();
const secs = Math.floor((now - d) / 1000); const secs = Math.floor((now.getTime() - d.getTime()) / 1000);
if (secs < 60) return i18n.t('admin.just_now'); if (secs < 60) return i18n.t('admin.just_now');
if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) }); if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) }); if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) });
@@ -60,6 +74,7 @@ function timeAgo(dateStr) {
} }
/* ── Custom confirm modal ── */ /* ── Custom confirm modal ── */
/** @param {string} message */
function showConfirm(message) { function showConfirm(message) {
return new Promise((resolve) => { return new Promise((resolve) => {
const overlay = document.getElementById('confirm-modal'); const overlay = document.getElementById('confirm-modal');
@@ -70,6 +85,7 @@ function showConfirm(message) {
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
overlay.classList.add('show-flex'); overlay.classList.add('show-flex');
/** @param {any} result */
function cleanup(result) { function cleanup(result) {
overlay.classList.remove('show-flex'); overlay.classList.remove('show-flex');
overlay.classList.add('hidden'); overlay.classList.add('hidden');
@@ -84,6 +100,7 @@ function showConfirm(message) {
function onNo() { function onNo() {
cleanup(false); cleanup(false);
} }
/** @param {Event} e */
function onOverlay(e) { function onOverlay(e) {
if (e.target === overlay) cleanup(false); if (e.target === overlay) cleanup(false);
} }
@@ -96,6 +113,10 @@ function showConfirm(message) {
/* ── Tab switching with fade animation ── */ /* ── Tab switching with fade animation ── */
let activeTabName = 'dashboard'; let activeTabName = 'dashboard';
/**
* @param {string} name
* @param {Element|undefined} el
*/
function switchTab(name, el) { function switchTab(name, el) {
if (name === activeTabName) return; if (name === activeTabName) return;
var oldTab = document.getElementById(`tab-${activeTabName}`); var oldTab = document.getElementById(`tab-${activeTabName}`);
@@ -158,7 +179,7 @@ async function loadDashboard() {
document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? i18n.t('admin.enabled') : i18n.t('admin.disabled'); document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? i18n.t('admin.enabled') : i18n.t('admin.disabled');
if (typeof d.registration_enabled !== 'undefined') { if (typeof d.registration_enabled !== 'undefined') {
document.getElementById('ds-registration').checked = d.registration_enabled; /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = d.registration_enabled;
if (d.registration_enabled) hideElement('registration-warning'); if (d.registration_enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex'); else showElement('registration-warning', 'flex');
} }
@@ -200,7 +221,7 @@ async function loadUsers() {
} }
tbody.innerHTML = users tbody.innerHTML = users
.map((u) => { .map((/** @type {any} */ u) => {
const quotaPct = u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0; const quotaPct = u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green'; const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green';
const quotaText = const quotaText =
@@ -306,18 +327,18 @@ async function loadUsers() {
.join(''); .join('');
// Set dynamic progress bar widths (CSP-safe via JS property) // Set dynamic progress bar widths (CSP-safe via JS property)
document.querySelectorAll('.progress-fill[data-width]').forEach((el) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.progress-fill[data-width]')).forEach((el) => {
el.style.width = `${el.dataset.width}%`; el.style.width = `${el.dataset.width}%`;
el.removeAttribute('data-width'); el.removeAttribute('data-width');
}); });
// Wire up admin action buttons (replaces inline onclick handlers) // Wire up admin action buttons (replaces inline onclick handlers)
document.querySelectorAll('.admin-action-btn').forEach((btn) => { /** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('.admin-action-btn')).forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const action = btn.dataset.action; const action = btn.dataset.action;
if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota)); if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota));
else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname); else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname);
else if (action === 'toggle-role') toggleRole(btn.dataset.uid, btn.dataset.role); else if (action === 'toggle-role') toggleRole(btn.dataset.uid, /** @type {RoleEnum} */ (btn.dataset.role));
else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true'); else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true');
else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname); else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname);
}); });
@@ -326,12 +347,12 @@ async function loadUsers() {
const from = usersPage * PAGE_SIZE + 1; const from = usersPage * PAGE_SIZE + 1;
const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers); const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers);
document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers }); document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers });
document.getElementById('prev-btn').disabled = usersPage === 0; /** @type {HTMLButtonElement} */ (document.getElementById('prev-btn')).disabled = usersPage === 0;
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers; /** @type {HTMLButtonElement} */ (document.getElementById('next-btn')).disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
} catch (e) { } catch (e) {
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' + '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('admin.error_network', { message: e.message })) + escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })) +
'</td></tr>'; '</td></tr>';
} }
} }
@@ -349,6 +370,10 @@ function nextPage() {
} }
} }
/**
* @param {string} userId
* @param {RoleEnum} currentRole
*/
async function toggleRole(userId, currentRole) { async function toggleRole(userId, currentRole) {
const newRole = currentRole === 'admin' ? 'user' : 'admin'; const newRole = currentRole === 'admin' ? 'user' : 'admin';
const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole })); const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole }));
@@ -366,10 +391,14 @@ async function toggleRole(userId, currentRole) {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
/**
* @param {string} userId
* @param {boolean} currentActive
*/
async function toggleActive(userId, currentActive) { async function toggleActive(userId, currentActive) {
const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate'); const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate');
const ok = await showConfirm(msg); const ok = await showConfirm(msg);
@@ -387,10 +416,14 @@ async function toggleActive(userId, currentActive) {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
/**
* @param {string} userId
* @param {string} username
*/
async function deleteUser(userId, username) { async function deleteUser(userId, username) {
const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username })); const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username }));
if (!ok) return; if (!ok) return;
@@ -408,17 +441,22 @@ async function deleteUser(userId, username) {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
let quotaUserId = ''; let quotaUserId = '';
/**
* @param {string} userId
* @param {string} username
* @param {number} currentQuota
*/
function openQuotaModal(userId, username, currentQuota) { function openQuotaModal(userId, username, currentQuota) {
quotaUserId = userId; quotaUserId = userId;
document.getElementById('qm-username').textContent = username; document.getElementById('qm-username').textContent = username;
const gb = currentQuota / 1073741824; const gb = currentQuota / 1073741824;
document.getElementById('qm-unit').value = '1073741824'; /** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value = '1073741824';
document.getElementById('qm-value').value = gb > 0 ? Math.round(gb * 10) / 10 : 0; /** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value = String(gb > 0 ? Math.round(gb * 10) / 10 : 0);
showElement('quota-modal', 'flex'); showElement('quota-modal', 'flex');
} }
function closeQuotaModal() { function closeQuotaModal() {
@@ -426,8 +464,8 @@ function closeQuotaModal() {
} }
async function saveQuota() { async function saveQuota() {
const val = parseFloat(document.getElementById('qm-value').value) || 0; const val = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value) || 0;
const unit = parseInt(document.getElementById('qm-unit').value, 10); const unit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value, 10);
const bytes = Math.round(val * unit); const bytes = Math.round(val * unit);
try { try {
const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, { const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, {
@@ -445,33 +483,33 @@ async function saveQuota() {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
function openCreateUserModal() { function openCreateUserModal() {
document.getElementById('cu-username').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value = '';
document.getElementById('cu-password').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value = '';
document.getElementById('cu-email').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value = '';
document.getElementById('cu-role').value = 'user'; /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value = 'user';
document.getElementById('cu-quota-value').value = '1'; /** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value = '1';
document.getElementById('cu-quota-unit').value = '1073741824'; /** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value = '1073741824';
document.getElementById('cu-error').className = 'alert'; document.getElementById('cu-error').className = 'alert';
document.getElementById('cu-error').textContent = ''; document.getElementById('cu-error').textContent = '';
showElement('create-user-modal', 'flex'); showElement('create-user-modal', 'flex');
setTimeout(() => document.getElementById('cu-username').focus(), 100); setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).focus(), 100);
} }
function closeCreateUserModal() { function closeCreateUserModal() {
hideElement('create-user-modal'); hideElement('create-user-modal');
} }
async function submitCreateUser() { async function submitCreateUser() {
const username = document.getElementById('cu-username').value.trim(); const username = /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value.trim();
const password = document.getElementById('cu-password').value; const password = /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value;
const email = document.getElementById('cu-email').value.trim() || null; const email = /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value.trim() || null;
const role = document.getElementById('cu-role').value; const role = /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value;
const quotaVal = parseFloat(document.getElementById('cu-quota-value').value) || 0; const quotaVal = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value) || 0;
const quotaUnit = parseInt(document.getElementById('cu-quota-unit').value, 10); const quotaUnit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value, 10);
const quotaBytes = Math.round(quotaVal * quotaUnit); const quotaBytes = Math.round(quotaVal * quotaUnit);
const errorEl = document.getElementById('cu-error'); const errorEl = document.getElementById('cu-error');
@@ -486,7 +524,7 @@ async function submitCreateUser() {
return; return;
} }
const btn = document.getElementById('cu-submit'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('cu-submit'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`;
try { try {
@@ -512,7 +550,7 @@ async function submitCreateUser() {
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
} catch (e) { } catch (e) {
errorEl.textContent = i18n.t('admin.error_network', { message: e.message }); errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
btn.disabled = false; btn.disabled = false;
@@ -520,21 +558,25 @@ async function submitCreateUser() {
} }
let resetPwUserId = ''; let resetPwUserId = '';
/**
* @param {string} userId
* @param {string} username
*/
function openResetPasswordModal(userId, username) { function openResetPasswordModal(userId, username) {
resetPwUserId = userId; resetPwUserId = userId;
document.getElementById('rp-username').textContent = username; document.getElementById('rp-username').textContent = username;
document.getElementById('rp-password').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value = '';
document.getElementById('rp-error').className = 'alert'; document.getElementById('rp-error').className = 'alert';
document.getElementById('rp-error').textContent = ''; document.getElementById('rp-error').textContent = '';
showElement('reset-pw-modal', 'flex'); showElement('reset-pw-modal', 'flex');
setTimeout(() => document.getElementById('rp-password').focus(), 100); setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).focus(), 100);
} }
function closeResetPasswordModal() { function closeResetPasswordModal() {
hideElement('reset-pw-modal'); hideElement('reset-pw-modal');
} }
async function submitResetPassword() { async function submitResetPassword() {
const password = document.getElementById('rp-password').value; const password = /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value;
const errorEl = document.getElementById('rp-error'); const errorEl = document.getElementById('rp-error');
if (password.length < 8) { if (password.length < 8) {
errorEl.textContent = i18n.t('admin.error_password_short'); errorEl.textContent = i18n.t('admin.error_password_short');
@@ -542,7 +584,7 @@ async function submitResetPassword() {
return; return;
} }
const btn = document.getElementById('rp-submit'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('rp-submit'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`;
try { try {
@@ -560,13 +602,14 @@ async function submitResetPassword() {
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
} catch (e) { } catch (e) {
errorEl.textContent = i18n.t('admin.error_network', { message: e.message }); errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`; btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`;
} }
/** @param {boolean} enabled */
async function toggleRegistration(enabled) { async function toggleRegistration(enabled) {
if (enabled) hideElement('registration-warning'); if (enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex'); else showElement('registration-warning', 'flex');
@@ -578,29 +621,33 @@ async function toggleRegistration(enabled) {
body: JSON.stringify({ registration_enabled: enabled }) body: JSON.stringify({ registration_enabled: enabled })
}); });
if (!resp.ok) { if (!resp.ok) {
document.getElementById('ds-registration').checked = !enabled; /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex'); if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning'); else hideElement('registration-warning');
const e = await resp.json().catch(() => ({})); const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
document.getElementById('ds-registration').checked = !enabled; /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex'); if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning'); else hideElement('registration-warning');
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
document.getElementById('oidc-enabled').addEventListener('change', function () { document.getElementById('oidc-enabled').addEventListener('change', function () {
if (this.checked) showElement('oidc-form'); if (/** @type {HTMLInputElement} */ (this).checked) showElement('oidc-form');
else hideElement('oidc-form'); else hideElement('oidc-form');
}); });
document.getElementById('disable-password').addEventListener('change', function () { document.getElementById('disable-password').addEventListener('change', function () {
if (this.checked) showElement('password-warning', 'flex'); if (/** @type {HTMLInputElement} */ (this).checked) showElement('password-warning', 'flex');
else hideElement('password-warning'); else hideElement('password-warning');
}); });
/**
* @param {string} msg
* @param {string} type
*/
function showOidcStatus(msg, type) { function showOidcStatus(msg, type) {
const el = document.getElementById('oidc-status'); const el = document.getElementById('oidc-status');
el.textContent = msg; el.textContent = msg;
@@ -613,12 +660,12 @@ function copyCallback() {
} }
async function testConnection() { async function testConnection() {
const url = document.getElementById('issuer-url').value.trim(); const url = /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim();
if (!url) { if (!url) {
showOidcStatus('Enter an Issuer URL first', 'error'); showOidcStatus('Enter an Issuer URL first', 'error');
return; return;
} }
const btn = document.getElementById('discover-btn'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('discover-btn'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`;
const resultDiv = document.getElementById('discovery-result'); const resultDiv = document.getElementById('discovery-result');
@@ -639,32 +686,32 @@ async function testConnection() {
'</dd><dt>Auth Endpoint</dt><dd>' + '</dd><dt>Auth Endpoint</dt><dd>' +
escapeHtml(r.authorization_endpoint || '—') + escapeHtml(r.authorization_endpoint || '—') +
'</dd></dl></div>'; '</dd></dl></div>';
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) if (!(/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value) && r.provider_name_suggestion)
document.getElementById('provider-name').value = r.provider_name_suggestion; /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = r.provider_name_suggestion;
} else { } else {
resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`; resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`;
} }
} catch (e) { } catch (e) {
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`; resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`; btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`;
} }
async function saveOidcSettings() { async function saveOidcSettings() {
const btn = document.getElementById('save-btn'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
const body = { const body = {
enabled: document.getElementById('oidc-enabled').checked, enabled: /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked,
issuer_url: document.getElementById('issuer-url').value.trim(), issuer_url: /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim(),
client_id: document.getElementById('client-id').value.trim(), client_id: /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value.trim(),
client_secret: document.getElementById('client-secret').value || null, client_secret: /** @type {HTMLInputElement} */ (document.getElementById('client-secret')).value || null,
scopes: document.getElementById('scopes').value.trim() || null, scopes: /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value.trim() || null,
auto_provision: document.getElementById('auto-provision').checked, auto_provision: /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked,
admin_groups: document.getElementById('admin-groups').value.trim() || null, admin_groups: /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value.trim() || null,
disable_password_login: document.getElementById('disable-password').checked, disable_password_login: /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked,
provider_name: document.getElementById('provider-name').value.trim() || null provider_name: /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value.trim() || null
}; };
try { try {
const resp = await fetch(`${API}/admin/settings/oidc`, { const resp = await fetch(`${API}/admin/settings/oidc`, {
@@ -682,7 +729,7 @@ async function saveOidcSettings() {
showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error'); showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showOidcStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showOidcStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.save_btn'))}`; btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.save_btn'))}`;
@@ -701,20 +748,25 @@ const STORAGE_PRESETS = {
'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false }, 'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false },
}; };
/** @param {boolean} visible */
function toggleS3Form(visible) { function toggleS3Form(visible) {
if (visible) showElement('storage-s3-form'); if (visible) showElement('storage-s3-form');
else hideElement('storage-s3-form'); else hideElement('storage-s3-form');
} }
function onStoragePresetChange() { function onStoragePresetChange() {
const preset = document.getElementById('storage-preset').value; const preset = /** @type {HTMLInputElement} */ (document.getElementById('storage-preset')).value;
const p = STORAGE_PRESETS[preset]; const p = STORAGE_PRESETS[/** @type {keyof typeof STORAGE_PRESETS} */ (preset)];
if (!p) return; if (!p) return;
if (p.endpoint) document.getElementById('storage-endpoint-url').value = p.endpoint; if (p.endpoint) /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = p.endpoint;
if (p.region) document.getElementById('storage-region').value = p.region; if (p.region) /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = p.region;
document.getElementById('storage-path-style').checked = p.pathStyle; /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = p.pathStyle;
} }
/**
* @param {string} msg
* @param {string} type
*/
function showStorageStatus(msg, type) { function showStorageStatus(msg, type) {
const el = document.getElementById('storage-status'); const el = document.getElementById('storage-status');
el.textContent = msg; el.textContent = msg;
@@ -732,21 +784,23 @@ async function loadStorage() {
// Backend selector // Backend selector
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => { document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
r.checked = r.value === s.backend; const input = /** @type {HTMLInputElement} */ (r);
input.checked = input.value === s.backend;
}); });
toggleS3Form(s.backend === 's3'); toggleS3Form(s.backend === 's3');
// S3 fields // S3 fields
document.getElementById('storage-endpoint-url').value = s.s3_endpoint_url || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = s.s3_endpoint_url || '';
document.getElementById('storage-bucket').value = s.s3_bucket || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value = s.s3_bucket || '';
document.getElementById('storage-region').value = s.s3_region || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = s.s3_region || '';
document.getElementById('storage-access-key').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value = '';
document.getElementById('storage-secret-key').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value = '';
document.getElementById('storage-path-style').checked = s.s3_force_path_style; /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = s.s3_force_path_style;
// Secret hints // Secret hints
if (s.s3_access_key_set) { if (s.s3_access_key_set) {
document.getElementById('storage-access-key').placeholder = i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value'; /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).placeholder =
i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value';
} }
if (s.s3_secret_key_set) { if (s.s3_secret_key_set) {
showElement('storage-secret-hint'); showElement('storage-secret-hint');
@@ -755,7 +809,7 @@ async function loadStorage() {
} }
// ENV badges // ENV badges
(s.env_overrides || []).forEach((field) => { /** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
const badge = document.getElementById(`badge-${field}`); const badge = document.getElementById(`badge-${field}`);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>'; if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
}); });
@@ -766,7 +820,7 @@ async function loadStorage() {
document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—'; document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—';
document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—'; document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—';
} catch (e) { } catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
// Also load migration status // Also load migration status
@@ -774,19 +828,19 @@ async function loadStorage() {
} }
async function saveStorageSettings() { async function saveStorageSettings() {
const btn = document.getElementById('btn-save-storage'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save-storage'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
const backend = document.querySelector('input[name="storage-backend"]:checked').value; const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
const body = { const body = {
backend, backend,
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null, s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
s3_bucket: document.getElementById('storage-bucket').value.trim() || null, s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
s3_region: document.getElementById('storage-region').value.trim() || null, s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
s3_access_key: document.getElementById('storage-access-key').value || null, s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
s3_secret_key: document.getElementById('storage-secret-key').value || null, s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
s3_force_path_style: document.getElementById('storage-path-style').checked s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
}; };
try { try {
@@ -804,26 +858,26 @@ async function saveStorageSettings() {
showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error'); showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`; btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`;
} }
async function testStorageConnection() { async function testStorageConnection() {
const btn = document.getElementById('btn-test-storage'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`;
const backend = document.querySelector('input[name="storage-backend"]:checked').value; const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
const body = { const body = {
backend, backend,
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null, s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
s3_bucket: document.getElementById('storage-bucket').value.trim() || null, s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
s3_region: document.getElementById('storage-region').value.trim() || null, s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
s3_access_key: document.getElementById('storage-access-key').value || null, s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
s3_secret_key: document.getElementById('storage-secret-key').value || null, s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
s3_force_path_style: document.getElementById('storage-path-style').checked s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
}; };
try { try {
@@ -842,7 +896,7 @@ async function testStorageConnection() {
showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error'); showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error');
} }
} catch (e) { } catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`; btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`;
@@ -850,8 +904,13 @@ async function testStorageConnection() {
/* ── Migration ── */ /* ── Migration ── */
/** @type {ReturnType<typeof setInterval> | null} */
let migrationPollTimer = null; let migrationPollTimer = null;
/**
* @param {string} msg
* @param {string} type
*/
function showMigrationMsg(msg, type) { function showMigrationMsg(msg, type) {
const el = document.getElementById('migration-status-msg'); const el = document.getElementById('migration-status-msg');
el.textContent = msg; el.textContent = msg;
@@ -859,6 +918,7 @@ function showMigrationMsg(msg, type) {
el.style.display = ''; el.style.display = '';
} }
/** @param {any} m */
function updateMigrationUI(m) { function updateMigrationUI(m) {
// Status badge // Status badge
const badge = document.getElementById('migration-status-badge'); const badge = document.getElementById('migration-status-badge');
@@ -937,7 +997,7 @@ async function loadMigrationStatus() {
} }
async function startMigration() { async function startMigration() {
const btn = document.getElementById('btn-start-migration'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-start-migration'));
btn.disabled = true; btn.disabled = true;
try { try {
const resp = await fetch(`${API}/admin/storage/migration/start`, { const resp = await fetch(`${API}/admin/storage/migration/start`, {
@@ -954,7 +1014,7 @@ async function startMigration() {
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error'); showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showMigrationMsg(i18n.t('admin.error_network', { message: e.message }), 'error'); showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
} }
@@ -992,7 +1052,7 @@ async function resumeMigration() {
} }
async function verifyMigration() { async function verifyMigration() {
const btn = document.getElementById('btn-verify-migration'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-verify-migration'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`;
const resultDiv = document.getElementById('migration-verify-result'); const resultDiv = document.getElementById('migration-verify-result');
@@ -1015,7 +1075,7 @@ async function verifyMigration() {
} }
} catch (e) { } catch (e) {
resultDiv.style.display = ''; resultDiv.style.display = '';
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`; resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`; btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`;
@@ -1036,7 +1096,7 @@ async function completeMigration() {
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error'); showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showMigrationMsg(i18n.t('admin.error_network', { message: e.message }), 'error'); showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
} }
@@ -1064,21 +1124,21 @@ async function init() {
}); });
if (oidcResp.ok) { if (oidcResp.ok) {
const s = await oidcResp.json(); const s = await oidcResp.json();
document.getElementById('oidc-enabled').checked = s.enabled; /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked = s.enabled;
if (s.enabled) showElement('oidc-form'); if (s.enabled) showElement('oidc-form');
else hideElement('oidc-form'); else hideElement('oidc-form');
document.getElementById('provider-name').value = s.provider_name || ''; /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = s.provider_name || '';
document.getElementById('issuer-url').value = s.issuer_url || ''; /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value = s.issuer_url || '';
document.getElementById('client-id').value = s.client_id || ''; /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value = s.client_id || '';
document.getElementById('scopes').value = s.scopes || 'openid profile email'; /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value = s.scopes || 'openid profile email';
document.getElementById('auto-provision').checked = s.auto_provision; /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked = s.auto_provision;
document.getElementById('admin-groups').value = s.admin_groups || ''; /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value = s.admin_groups || '';
document.getElementById('disable-password').checked = s.disable_password_login; /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked = s.disable_password_login;
if (s.disable_password_login) showElement('password-warning', 'flex'); if (s.disable_password_login) showElement('password-warning', 'flex');
else hideElement('password-warning'); else hideElement('password-warning');
document.getElementById('callback-url').textContent = s.callback_url; document.getElementById('callback-url').textContent = s.callback_url;
if (s.client_secret_set) showElement('secret-hint'); if (s.client_secret_set) showElement('secret-hint');
(s.env_overrides || []).forEach((field) => { /** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
const badge = document.getElementById(`badge-${field}`); const badge = document.getElementById(`badge-${field}`);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>'; if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
}); });
@@ -1128,7 +1188,7 @@ document.getElementById('tab-btn-storage').addEventListener('click', function ()
}); });
document.getElementById('ds-registration').addEventListener('change', function () { document.getElementById('ds-registration').addEventListener('change', function () {
toggleRegistration(this.checked); toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
}); });
document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal); document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal);
@@ -1151,8 +1211,8 @@ document.getElementById('rp-submit').addEventListener('click', submitResetPasswo
/* ── Storage event listeners ── */ /* ── Storage event listeners ── */
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => { document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
r.addEventListener('change', function () { r.addEventListener('change', () => {
toggleS3Form(this.value === 's3'); toggleS3Form(/** @type {HTMLInputElement} */ (r).value === 's3');
}); });
}); });
document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange); document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange);
+21 -5
View File
@@ -8,8 +8,10 @@ import { oxiIconsInit } from '../../core/icons.js';
var deviceInfo = document.getElementById('device-info'); var deviceInfo = document.getElementById('device-info');
var actionButtons = document.getElementById('action-buttons'); var actionButtons = document.getElementById('action-buttons');
var errorText = document.getElementById('error-text'); var errorText = document.getElementById('error-text');
var btnApprove = document.getElementById('btn-approve'); var btnApprove = /** @type {HTMLButtonElement} */ (document.getElementById('btn-approve'));
var btnDeny = document.getElementById('btn-deny'); var btnDeny = /** @type {HTMLButtonElement} */ (document.getElementById('btn-deny'));
/** @type {ReturnType<typeof setTimeout>} */
var debounceTimer = null; var debounceTimer = null;
var currentCode = ''; var currentCode = '';
@@ -24,12 +26,13 @@ import { oxiIconsInit } from '../../core/icons.js';
// Auto-insert hyphen and lookup on input // Auto-insert hyphen and lookup on input
codeInput.addEventListener('input', (e) => { codeInput.addEventListener('input', (e) => {
var val = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, ''); const target = /** @type {HTMLInputElement} */ (e.target);
var val = target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
// Auto-insert hyphen after 4 chars // Auto-insert hyphen after 4 chars
if (val.length === 4 && val.indexOf('-') === -1) { if (val.length === 4 && val.indexOf('-') === -1) {
val = `${val}-`; val = `${val}-`;
} }
e.target.value = val; target.value = val;
errorText.classList.add('hidden'); errorText.classList.add('hidden');
// Debounce lookup // Debounce lookup
@@ -52,6 +55,11 @@ import { oxiIconsInit } from '../../core/icons.js';
handleAction('deny'); handleAction('deny');
}); });
/**
*
* @param {string} code
* @returns
*/
async function lookupCode(code) { async function lookupCode(code) {
try { try {
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, { const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
@@ -81,6 +89,10 @@ import { oxiIconsInit } from '../../core/icons.js';
} }
} }
/**
*
* @param {'approve' | 'deny'} action
*/
async function handleAction(action) { async function handleAction(action) {
btnApprove.disabled = true; btnApprove.disabled = true;
btnDeny.disabled = true; btnDeny.disabled = true;
@@ -109,10 +121,14 @@ import { oxiIconsInit } from '../../core/icons.js';
} catch (err) { } catch (err) {
btnApprove.disabled = false; btnApprove.disabled = false;
btnDeny.disabled = false; btnDeny.disabled = false;
showError(err.message || 'Failed to process action.'); showError(/** @type {Error} */ (err).message || 'Failed to process action.');
} }
} }
/**
*
* @param {string} msg
*/
function showError(msg) { function showError(msg) {
errorText.textContent = msg; errorText.textContent = msg;
errorText.classList.remove('hidden'); errorText.classList.remove('hidden');
+1 -1
View File
@@ -6,7 +6,7 @@ if (!/^[0-9a-fA-F]+$/.test(token)) {
document.body.innerHTML = '<p>Invalid session token.</p>'; document.body.innerHTML = '<p>Invalid session token.</p>';
throw new Error('Invalid token format'); throw new Error('Invalid token format');
} }
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`; /** @type {HTMLFormElement} */ (document.getElementById('login-flow-form')).action = `/login/v2/flow/${token}`;
// Check if OIDC is available and configure SSO button // Check if OIDC is available and configure SSO button
(async () => { (async () => {
+32 -14
View File
@@ -4,10 +4,16 @@ import { oxiIconsInit } from '../../core/icons.js';
const API = '/api'; const API = '/api';
// TOOD: reuse common library
/**
* @returns {Record<string, string>}
*/
function headers() { function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
} }
// TOOD: move to common library
/** @param {number} bytes */
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024, const k = 1024,
@@ -16,11 +22,13 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
} }
// TOOD: move to common library
/** @param {string | null | undefined} dateStr */
function timeAgo(dateStr) { function timeAgo(dateStr) {
if (!dateStr) return i18n.t('profile.never'); if (!dateStr) return i18n.t('profile.never');
const d = new Date(dateStr); const d = new Date(dateStr);
const now = new Date(); const now = Date.now();
const secs = Math.floor((now - d) / 1000); const secs = Math.floor((now - d.valueOf()) / 1000);
if (secs < 60) return i18n.t('profile.just_now'); if (secs < 60) return i18n.t('profile.just_now');
if (secs < 3600) return i18n.t('profile.minutes_ago', { n: Math.floor(secs / 60) }); if (secs < 3600) return i18n.t('profile.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return i18n.t('profile.hours_ago', { n: Math.floor(secs / 3600) }); if (secs < 86400) return i18n.t('profile.hours_ago', { n: Math.floor(secs / 3600) });
@@ -104,11 +112,12 @@ function showError() {
document.getElementById('auth-error').classList.remove('hidden'); document.getElementById('auth-error').classList.remove('hidden');
} }
/** @param {Event} e */
async function changePassword(e) { async function changePassword(e) {
e.preventDefault(); e.preventDefault();
const currentPw = document.getElementById('current-password').value; const currentPw = /** @type {HTMLInputElement} */ (document.getElementById('current-password')).value;
const newPw = document.getElementById('new-password').value; const newPw = /** @type {HTMLInputElement} */ (document.getElementById('new-password')).value;
const confirmPw = document.getElementById('confirm-password').value; const confirmPw = /** @type {HTMLInputElement} */ (document.getElementById('confirm-password')).value;
const statusEl = document.getElementById('pw-status'); const statusEl = document.getElementById('pw-status');
if (newPw !== confirmPw) { if (newPw !== confirmPw) {
@@ -121,7 +130,7 @@ async function changePassword(e) {
return false; return false;
} }
const btn = document.getElementById('pw-submit'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('pw-submit'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('profile.updating'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('profile.updating'))}`;
@@ -138,7 +147,7 @@ async function changePassword(e) {
if (resp.ok) { if (resp.ok) {
statusEl.innerHTML = `<div class="alert alert-success"><i class="fas fa-check-circle"></i> ${escapeHtml(i18n.t('profile.password_updated'))}</div>`; statusEl.innerHTML = `<div class="alert alert-success"><i class="fas fa-check-circle"></i> ${escapeHtml(i18n.t('profile.password_updated'))}</div>`;
document.getElementById('password-form').reset(); /** @type {HTMLFormElement} */ (document.getElementById('password-form')).reset();
} else { } else {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = statusEl.innerHTML =
@@ -149,7 +158,7 @@ async function changePassword(e) {
} catch (err) { } catch (err) {
statusEl.innerHTML = statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('profile.error_network', { message: err.message })) + escapeHtml(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message })) +
'</div>'; '</div>';
} }
@@ -162,10 +171,12 @@ async function changePassword(e) {
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)']; const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
/** @param {{label: string, active?: boolean, id: string}} pw */
function isAutoPassword(pw) { function isAutoPassword(pw) {
return AUTO_LABELS.includes(pw.label); return AUTO_LABELS.includes(pw.label);
} }
/** @param {{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}} pw */
function renderPwRow(pw) { function renderPwRow(pw) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const label = document.createElement('td'); const label = document.createElement('td');
@@ -210,7 +221,9 @@ async function loadAppPasswords() {
return; return;
} }
const data = await resp.json(); const data = await resp.json();
const passwords = data.app_passwords || data; const passwords = /** @type {Array<{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}>} */ (
data.app_passwords || data
);
const userPws = passwords.filter((pw) => { const userPws = passwords.filter((pw) => {
return !isAutoPassword(pw); return !isAutoPassword(pw);
}); });
@@ -236,7 +249,7 @@ async function loadAppPasswords() {
autoSection.classList.add('hidden'); autoSection.classList.add('hidden');
} else { } else {
autoSection.classList.remove('hidden'); autoSection.classList.remove('hidden');
document.getElementById('app-pw-auto-count').textContent = autoPws.length; document.getElementById('app-pw-auto-count').textContent = String(autoPws.length);
const autoTbody = document.getElementById('app-pw-auto-tbody'); const autoTbody = document.getElementById('app-pw-auto-tbody');
autoTbody.innerHTML = ''; autoTbody.innerHTML = '';
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw)); for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
@@ -255,10 +268,10 @@ function toggleAutoPasswords() {
} }
async function createAppPassword() { async function createAppPassword() {
const labelInput = document.getElementById('app-pw-label'); const labelInput = /** @type {HTMLInputElement} */ (document.getElementById('app-pw-label'));
const label = labelInput.value.trim(); const label = labelInput.value.trim();
const statusEl = document.getElementById('app-pw-status'); const statusEl = document.getElementById('app-pw-status');
const btn = document.getElementById('app-pw-generate'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('app-pw-generate'));
if (!label) { if (!label) {
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.error_label_required'))}</div>`; statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.error_label_required'))}</div>`;
@@ -291,7 +304,7 @@ async function createAppPassword() {
labelInput.value = ''; labelInput.value = '';
loadAppPasswords(); loadAppPasswords();
} catch (err) { } catch (err) {
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${err.message}</div>`; statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${/** @type {Error} */ (err).message}</div>`;
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('profile.generate'))}`; btn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('profile.generate'))}`;
@@ -309,6 +322,10 @@ function copyAppPassword() {
}); });
} }
/**
* @param {string} id
* @param {string} label
*/
async function revokeAppPassword(id, label) { async function revokeAppPassword(id, label) {
if (!confirm(i18n.t('profile.confirm_revoke', { label: label }))) return; if (!confirm(i18n.t('profile.confirm_revoke', { label: label }))) return;
try { try {
@@ -325,10 +342,11 @@ async function revokeAppPassword(id, label) {
alert(err.message || i18n.t('profile.error_revoke')); alert(err.message || i18n.t('profile.error_revoke'));
} }
} catch (err) { } catch (err) {
alert(i18n.t('profile.error_network', { message: err.message })); alert(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message }));
} }
} }
/** @param {string} str */
function escapeHtml(str) { function escapeHtml(str) {
var div = document.createElement('div'); var div = document.createElement('div');
div.textContent = str || ''; div.textContent = str || '';
+98 -21
View File
@@ -17,12 +17,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const $folder = document.getElementById('share-folder'); const $folder = document.getElementById('share-folder');
const $pwForm = document.getElementById('password-form'); const $pwForm = document.getElementById('password-form');
const $pwInput = document.getElementById('password-input'); const $pwInput = /** @type {HTMLInputElement} */ (document.getElementById('password-input'));
const $pwError = document.getElementById('password-error'); const $pwError = document.getElementById('password-error');
const $fileName = document.getElementById('file-name'); const $fileName = document.getElementById('file-name');
const $fileMeta = document.getElementById('file-meta'); const $fileMeta = document.getElementById('file-meta');
const $fileDl = document.getElementById('file-download'); const $fileDl = /** @type {HTMLAnchorElement} */ (document.getElementById('file-download'));
const $expiredMsg = document.getElementById('expired-message'); const $expiredMsg = document.getElementById('expired-message');
// ── Token from URL path (/s/{token}) ────────────────────────── // ── Token from URL path (/s/{token}) ──────────────────────────
@@ -49,35 +49,49 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
let rootDisplayName = 'Shared folder'; let rootDisplayName = 'Shared folder';
/**
* @param {'loading'|'password'|'expired'|'file'|'folder'} name
*/
function showState(name) { function showState(name) {
for (const el of [$loading, $password, $expired, $file, $folder]) { for (const el of [$loading, $password, $expired, $file, $folder]) {
if (el) el.classList.add('hidden'); if (el) el.classList.add('hidden');
} }
const target = { /** @type {{ loading: HTMLElement|null, password: HTMLElement|null, expired: HTMLElement|null, file: HTMLElement|null, folder: HTMLElement|null }} */
const map = {
loading: $loading, loading: $loading,
password: $password, password: $password,
expired: $expired, expired: $expired,
file: $file, file: $file,
folder: $folder folder: $folder
}[name]; };
const target = map[name];
if (target) target.classList.remove('hidden'); if (target) target.classList.remove('hidden');
document.body.classList.toggle('gallery-mode', name === 'folder'); document.body.classList.toggle('gallery-mode', name === 'folder');
} }
// ── Utilities ───────────────────────────────────────────────── // ── Utilities ─────────────────────────────────────────────────
/**
* @param {string|null|undefined} s
* @returns {string}
*/
function escapeHtml(s) { function escapeHtml(s) {
return String(s == null ? '' : s).replace( return String(s == null ? '' : s).replace(
/[&<>"']/g, /[&<>"']/g,
(c) => (c) =>
/** @type {Record<string, string>} */
({ ({
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
'>': '&gt;', '>': '&gt;',
'"': '&quot;', '"': '&quot;',
"'": '&#39;' "'": '&#39;'
})[c] })[c] ?? c
); );
} }
/**
* @param {number} bytes
* @returns {string}
*/
function formatSize(bytes) { function formatSize(bytes) {
if (bytes == null || Number.isNaN(bytes)) return ''; if (bytes == null || Number.isNaN(bytes)) return '';
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -90,6 +104,10 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
} }
/**
* @param {string|null|undefined} mime
* @returns {'image'|'video'|null}
*/
function mediaKind(mime) { function mediaKind(mime) {
const m = (mime || '').toLowerCase(); const m = (mime || '').toLowerCase();
if (m.startsWith('image/')) return 'image'; if (m.startsWith('image/')) return 'image';
@@ -97,6 +115,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return null; return null;
} }
// ── Render share data ───────────────────────────────────────── // ── Render share data ─────────────────────────────────────────
/**
* @param {any} data
*/
function renderShare(data) { function renderShare(data) {
if (data.item_type === 'folder') { if (data.item_type === 'folder') {
rootDisplayName = data.item_name || 'Shared folder'; rootDisplayName = data.item_name || 'Shared folder';
@@ -114,13 +135,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
.then((res) => { .then((res) => {
if (res.ok) return res.json(); if (res.ok) return res.json();
if (res.status === 401) { if (res.status === 401) {
return res.json().then((body) => { return res.json().then(
if (body?.requiresPassword) { /** @type {(body:any) => null} */ (body) => {
showState('password'); if (body?.requiresPassword) {
return null; showState('password');
return null;
}
throw new Error('Unauthorized');
} }
throw new Error('Unauthorized'); );
});
} }
if (res.status === 410) { if (res.status === 410) {
showState('expired'); showState('expired');
@@ -171,7 +194,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
// ── Folder gallery ──────────────────────────────────────────── // ── Folder gallery ────────────────────────────────────────────
/** @type {string | null} */
let currentFolderId = null; let currentFolderId = null;
/** @type {string | null} */
let currentFolderName = null; let currentFolderName = null;
function initFolderGallery() { function initFolderGallery() {
@@ -195,17 +220,33 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return m ? m[1] : null; return m ? m[1] : null;
} }
/**
* @param {string | null} folderId
* @returns {string}
*/
function listingUrl(folderId) { function listingUrl(folderId) {
return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`; return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`;
} }
/**
* @param {string} fileId
* @returns {string}
*/
function fileUrl(fileId) { function fileUrl(fileId) {
return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`; return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`;
} }
/**
* @param {string | null} folderId
* @returns {string}
*/
function zipUrl(folderId) { function zipUrl(folderId) {
return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`; return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`;
} }
/** @type {AbortController | null} */
let currentLoadController = null; let currentLoadController = null;
/**
* @param {string | null} folderId
*/
function loadAndRender(folderId) { function loadAndRender(folderId) {
if (currentLoadController) currentLoadController.abort(); if (currentLoadController) currentLoadController.abort();
const controller = new AbortController(); const controller = new AbortController();
@@ -236,6 +277,10 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}); });
} }
/**
* @param {any} listing
* @param {string | null} folderId
*/
function renderGallery(listing, folderId) { function renderGallery(listing, folderId) {
const isSubfolder = folderId !== null; const isSubfolder = folderId !== null;
const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName; const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName;
@@ -252,7 +297,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const filesHtml = const filesHtml =
listing.files && listing.files.length > 0 listing.files && listing.files.length > 0
? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((f) => fileCardHtml(f)).join('')}</div>` ? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((/** @type {any} */ f) => fileCardHtml(f)).join('')}</div>`
: ''; : '';
const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : ''; const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : '';
@@ -265,10 +310,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
wireImageRetry(); wireImageRetry();
} }
/**
* @param {any} folder
* @returns {string}
*/
function folderCardHtml(folder) { function folderCardHtml(folder) {
return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`; return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`;
} }
/**
* @param {any} file
* @returns {string}
*/
function fileCardHtml(file) { function fileCardHtml(file) {
const url = fileUrl(file.id); const url = fileUrl(file.id);
const kind = mediaKind(file.mime_type); const kind = mediaKind(file.mime_type);
@@ -286,7 +339,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
function wireGallery() { function wireGallery() {
for (const btn of $folder.querySelectorAll('.gallery-view-toggle button')) { for (const btn of /** @type {NodeListOf<HTMLButtonElement>} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) {
btn.addEventListener('click', () => setViewMode(btn.dataset.view)); btn.addEventListener('click', () => setViewMode(btn.dataset.view));
} }
const backBtn = $folder.querySelector('[data-action="back"]'); const backBtn = $folder.querySelector('[data-action="back"]');
@@ -296,14 +349,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
navigate(null, rootDisplayName); navigate(null, rootDisplayName);
}); });
} }
for (const card of $folder.querySelectorAll('[data-action="open-folder"]')) { for (const card of /** @type {NodeListOf<HTMLDivElement>} */ ($folder.querySelectorAll('[data-action="open-folder"]'))) {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (!(e instanceof MouseEvent)) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault(); e.preventDefault();
navigate(card.dataset.id, card.dataset.name); navigate(card.dataset.id, card.dataset.name);
}); });
} }
const mediaCards = Array.from($folder.querySelectorAll('.file-card[data-mediakind]')); const mediaCards = Array.from(/** @type {NodeListOf<HTMLDivElement>} */ ($folder.querySelectorAll('.file-card[data-mediakind]')));
const items = mediaCards.map((el) => ({ const items = mediaCards.map((el) => ({
kind: el.dataset.mediakind, kind: el.dataset.mediakind,
src: el.dataset.src, src: el.dataset.src,
@@ -318,6 +372,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}); });
} }
/**
* @param {string | null | undefined} v
*/
function setViewMode(v) { function setViewMode(v) {
const mode = v === 'list' ? 'list' : 'grid'; const mode = v === 'list' ? 'list' : 'grid';
viewMode = mode; viewMode = mode;
@@ -327,11 +384,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
// ignore // ignore
} }
document.body.dataset.shareView = mode; document.body.dataset.shareView = mode;
for (const b of $folder.querySelectorAll('.gallery-view-toggle button')) { for (const b of /** @type {NodeListOf<HTMLButtonElement>} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) {
b.setAttribute('aria-pressed', String(b.dataset.view === mode)); b.setAttribute('aria-pressed', String(b.dataset.view === mode));
} }
} }
/**
* @param {string | null} folderId
* @param {string | null | undefined} folderName
*/
function navigate(folderId, folderName) { function navigate(folderId, folderName) {
currentFolderId = folderId; currentFolderId = folderId;
currentFolderName = folderName; currentFolderName = folderName;
@@ -352,7 +413,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
function wireLazyVideos() { function wireLazyVideos() {
const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]'); const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]');
if (!lazy.length) return; if (!lazy.length) return;
const start = (v) => { const start = (/** @type {HTMLVideoElement} */ v) => {
v.addEventListener( v.addEventListener(
'loadedmetadata', 'loadedmetadata',
() => { () => {
@@ -385,20 +446,21 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
(entries) => { (entries) => {
for (const e of entries) { for (const e of entries) {
if (!e.isIntersecting) continue; if (!e.isIntersecting) continue;
if (e.target.dataset.lazySrc && !e.target.src) start(e.target); const target = /** @type {HTMLVideoElement} */ (e.target);
obs.unobserve(e.target); if (target.dataset.lazySrc && !target.src) start(target);
obs.unobserve(target);
} }
}, },
{ rootMargin: '300px' } { rootMargin: '300px' }
); );
for (const v of lazy) obs.observe(v); for (const v of lazy) obs.observe(v);
} else { } else {
for (const v of lazy) start(v); for (const v of lazy) start(/** @type {HTMLVideoElement} */ (v));
} }
} }
function wireImageRetry() { function wireImageRetry() {
for (const img of $folder.querySelectorAll('.file-thumb img')) { for (const img of /** @type {NodeListOf<HTMLImageElement>} */ ($folder.querySelectorAll('.file-thumb img'))) {
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.retried === '1') return; if (img.dataset.retried === '1') return;
img.dataset.retried = '1'; img.dataset.retried = '1';
@@ -412,7 +474,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
// ── Lightbox ────────────────────────────────────────────────── // ── Lightbox ──────────────────────────────────────────────────
/**
* @typedef {{ root: HTMLElement, title: Element|null, download: HTMLAnchorElement|null, close: HTMLButtonElement|null, stage: Element|null, content: Element|null, prev: HTMLButtonElement|null, next: HTMLButtonElement|null }} LightboxRefs
*/
/** @type {LightboxRefs | null} */
let lb = null; let lb = null;
/** @type {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} */
let lbItems = []; let lbItems = [];
let lbIndex = -1; let lbIndex = -1;
@@ -466,11 +533,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return lb; return lb;
} }
/**
* @param {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} items
* @param {number} index
*/
function openLightbox(items, index) { function openLightbox(items, index) {
ensureLightbox(); ensureLightbox();
lbItems = items; lbItems = items;
showLightboxItem(index); showLightboxItem(index);
} }
/**
* @param {number} i
*/
function showLightboxItem(i) { function showLightboxItem(i) {
if (i < 0 || i >= lbItems.length) return; if (i < 0 || i >= lbItems.length) return;
lbIndex = i; lbIndex = i;
@@ -497,6 +571,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
lb.root.classList.remove('hidden'); lb.root.classList.remove('hidden');
lb.root.setAttribute('aria-hidden', 'false'); lb.root.setAttribute('aria-hidden', 'false');
} }
/**
* @param {number} delta
*/
function stepLightbox(delta) { function stepLightbox(delta) {
const next = lbIndex + delta; const next = lbIndex + delta;
if (next >= 0 && next < lbItems.length) showLightboxItem(next); if (next >= 0 && next < lbItems.length) showLightboxItem(next);
+76 -76
View File
@@ -6,18 +6,18 @@
import { switchToFilesSection } from '../../app/navigation.js'; import { switchToFilesSection } from '../../app/navigation.js';
import { ui } from '../../app/ui.js'; import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateShort } from '../../core/formatters.js'; import { formatDateShort, isEmailValid } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { fileSharing } from '../../features/sharing/fileSharing.js'; import { fileSharing } from '../../features/sharing/fileSharing.js';
/** @import {Share} from '../../core/types.js' */ /** @import {ShareItem} from '../../core/types.js' */
const TTL = 5 * 60 * 1000; // 5 min const TTL = 5 * 60 * 1000; // 5 min
const sharedView = { const sharedView = {
// State // State
/** @type {Array<Share>} */ /** @type {Array<ShareItem>} */
items: [], items: [],
_expires: 0, _expires: 0,
@@ -25,8 +25,10 @@ const sharedView = {
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */ /** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
_knownItemsId: new Map(), _knownItemsId: new Map(),
/** @type {Array<Share>} */ /** @type {Array<ShareItem>} */
filteredItems: [], filteredItems: [],
/** @type {ShareItem | null} */
currentItem: null, currentItem: null,
/** Auth header helper — tokens are in HttpOnly cookies now */ /** Auth header helper — tokens are in HttpOnly cookies now */
@@ -238,6 +240,7 @@ const sharedView = {
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
document.querySelectorAll('.shared-custom-select.open').forEach((sel) => { document.querySelectorAll('.shared-custom-select.open').forEach((sel) => {
if (!(e.target instanceof Node)) return;
if (!sel.contains(e.target)) sel.classList.remove('open'); if (!sel.contains(e.target)) sel.classList.remove('open');
}); });
}); });
@@ -249,8 +252,8 @@ const sharedView = {
if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog()); if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog());
const copyLinkBtn = document.getElementById('sv-copy-link-btn'); const copyLinkBtn = document.getElementById('sv-copy-link-btn');
if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink()); if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink());
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
if (enablePw) if (enablePw)
enablePw.addEventListener('change', () => { enablePw.addEventListener('change', () => {
if (pwField) { if (pwField) {
@@ -260,8 +263,8 @@ const sharedView = {
}); });
const genPwBtn = document.getElementById('sv-generate-password'); const genPwBtn = document.getElementById('sv-generate-password');
if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword()); if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword());
const enableExp = document.getElementById('sv-enable-expiration'); const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = document.getElementById('sv-share-expiration'); const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
if (enableExp) if (enableExp)
enableExp.addEventListener('change', () => { enableExp.addEventListener('change', () => {
if (expField) { if (expField) {
@@ -294,6 +297,13 @@ const sharedView = {
}, },
// Initialize a custom select dropdown // Initialize a custom select dropdown
/**
*
* @param {string} wrapperId
* @param {string} toggleId
* @param {string} dropdownId
* @returns
*/
_initCustomSelect(wrapperId, toggleId, dropdownId) { _initCustomSelect(wrapperId, toggleId, dropdownId) {
const wrapper = document.getElementById(wrapperId); const wrapper = document.getElementById(wrapperId);
const toggle = document.getElementById(toggleId); const toggle = document.getElementById(toggleId);
@@ -330,14 +340,14 @@ const sharedView = {
// Filter and sort items // Filter and sort items
filterAndSortItems() { filterAndSortItems() {
const filterTypeActive = document.querySelector('#filter-type-dropdown .shared-select-option.active'); const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-dropdown .shared-select-option.active'));
const sortByActive = document.querySelector('#sort-by-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 type = filterTypeActive ? filterTypeActive.dataset.value : 'all';
const sort = sortByActive ? sortByActive.dataset.value : 'date'; const sort = sortByActive ? sortByActive.dataset.value : 'date';
// Use the main top-bar search input // Use the main top-bar search input
const searchInput = document.getElementById('search-input'); const searchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input'));
const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
this.filteredItems = this.items.filter((item) => { this.filteredItems = this.items.filter((item) => {
@@ -396,23 +406,23 @@ const sharedView = {
nameCell.appendChild(nameSpan); nameCell.appendChild(nameSpan);
const typeCell = document.createElement('td'); const typeCell = document.createElement('td');
typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder'); typeCell.textContent = item.item_type === 'file' ? i18n.t('shared_typeFile', 'File') : i18n.t('shared_typeFolder', 'Folder');
const dateCell = document.createElement('td'); const dateCell = document.createElement('td');
dateCell.textContent = this.formatDate(item.created_at); dateCell.textContent = formatDateShort(item.created_at);
const expCell = document.createElement('td'); const expCell = document.createElement('td');
expCell.textContent = item.expires_at ? this.formatDate(item.expires_at) : this.translate('shared_noExpiration', 'No expiration'); expCell.textContent = item.expires_at ? formatDateShort(item.expires_at) : i18n.t('shared_noExpiration', 'No expiration');
const permCell = document.createElement('td'); const permCell = document.createElement('td');
const perms = []; const perms = [];
if (item.permissions?.read) perms.push(this.translate('share_permissionRead', 'Read')); if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read'));
if (item.permissions?.write) perms.push(this.translate('share_permissionWrite', 'Write')); if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write'));
if (item.permissions?.reshare) perms.push(this.translate('share_permissionReshare', 'Reshare')); if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare'));
permCell.textContent = perms.join(', ') || 'Read'; permCell.textContent = perms.join(', ') || 'Read';
const pwCell = document.createElement('td'); const pwCell = document.createElement('td');
pwCell.textContent = item.has_password ? this.translate('shared_hasPassword', 'Yes') : this.translate('shared_noPassword', 'No'); pwCell.textContent = item.has_password ? i18n.t('shared_hasPassword', 'Yes') : i18n.t('shared_noPassword', 'No');
const actionsCell = document.createElement('td'); const actionsCell = document.createElement('td');
actionsCell.className = 'shared-item-actions'; actionsCell.className = 'shared-item-actions';
@@ -420,30 +430,30 @@ const sharedView = {
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'action-btn edit-btn'; editBtn.className = 'action-btn edit-btn';
editBtn.innerHTML = '<span class="action-icon">✏️</span>'; editBtn.innerHTML = '<span class="action-icon">✏️</span>';
editBtn.title = this.translate('shared_editShare', 'Edit Share'); editBtn.title = i18n.t('shared_editShare', 'Edit Share');
editBtn.addEventListener('click', () => this.openShareDialog(item)); editBtn.addEventListener('click', () => this.openShareDialog(item));
const notifyBtn = document.createElement('button'); const notifyBtn = document.createElement('button');
notifyBtn.className = 'action-btn notify-btn'; notifyBtn.className = 'action-btn notify-btn';
notifyBtn.innerHTML = '<span class="action-icon">📧</span>'; notifyBtn.innerHTML = '<span class="action-icon">📧</span>';
notifyBtn.title = this.translate('shared_notifyShare', 'Notify Someone'); notifyBtn.title = i18n.t('shared_notifyShare', 'Notify Someone');
notifyBtn.addEventListener('click', () => this.openNotificationDialog(item)); notifyBtn.addEventListener('click', () => this.openNotificationDialog(item));
const copyBtn = document.createElement('button'); const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn copy-btn'; copyBtn.className = 'action-btn copy-btn';
copyBtn.innerHTML = '<span class="action-icon">📋</span>'; copyBtn.innerHTML = '<span class="action-icon">📋</span>';
copyBtn.title = this.translate('shared_copyLink', 'Copy Link'); copyBtn.title = i18n.t('shared_copyLink', 'Copy Link');
copyBtn.addEventListener('click', () => { copyBtn.addEventListener('click', () => {
navigator.clipboard navigator.clipboard
.writeText(item.url) .writeText(item.url)
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
}); });
const rmBtn = document.createElement('button'); const rmBtn = document.createElement('button');
rmBtn.className = 'action-btn remove-btn'; rmBtn.className = 'action-btn remove-btn';
rmBtn.innerHTML = '<span class="action-icon">🗑️</span>'; rmBtn.innerHTML = '<span class="action-icon">🗑️</span>';
rmBtn.title = this.translate('shared_removeShare', 'Remove Share'); rmBtn.title = i18n.t('shared_removeShare', 'Remove Share');
rmBtn.addEventListener('click', () => { rmBtn.addEventListener('click', () => {
this.currentItem = item; this.currentItem = item;
this.removeSharedItem(); this.removeSharedItem();
@@ -456,6 +466,11 @@ const sharedView = {
}, },
// Open share dialog // Open share dialog
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openShareDialog(item) { openShareDialog(item) {
this.currentItem = item; this.currentItem = item;
const shareDialog = document.getElementById('shared-view-edit-dialog'); const shareDialog = document.getElementById('shared-view-edit-dialog');
@@ -463,14 +478,14 @@ const sharedView = {
const iconEl = document.getElementById('sv-dialog-icon'); const iconEl = document.getElementById('sv-dialog-icon');
const nameEl = document.getElementById('sv-dialog-name'); const nameEl = document.getElementById('sv-dialog-name');
const urlEl = document.getElementById('sv-share-link-url'); const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = document.getElementById('sv-enable-expiration'); const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = document.getElementById('sv-share-expiration'); const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const permRead = document.getElementById('sv-permission-read'); const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = document.getElementById('sv-permission-write'); const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = document.getElementById('sv-permission-reshare'); const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
if (!shareDialog) return; if (!shareDialog) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
@@ -505,14 +520,19 @@ const sharedView = {
this.currentItem = null; this.currentItem = null;
}, },
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openNotificationDialog(item) { openNotificationDialog(item) {
this.currentItem = item; this.currentItem = item;
const dn = item.item_name || item.item_id || 'Unknown'; const dn = item.item_name || item.item_id || 'Unknown';
const d = document.getElementById('sv-notification-dialog'); const d = document.getElementById('sv-notification-dialog');
const iconEl = document.getElementById('sv-notify-dialog-icon'); const iconEl = document.getElementById('sv-notify-dialog-icon');
const nameEl = document.getElementById('sv-notify-dialog-name'); const nameEl = document.getElementById('sv-notify-dialog-name');
const emailEl = document.getElementById('sv-notification-email'); const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = document.getElementById('sv-notification-message'); const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
if (!d) return; if (!d) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
@@ -529,18 +549,18 @@ const sharedView = {
}, },
copyShareLink() { copyShareLink() {
const el = document.getElementById('sv-share-link-url'); const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
if (!el) return; if (!el) return;
navigator.clipboard navigator.clipboard
.writeText(el.value) .writeText(el.value)
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
}, },
// Generate secure password with crypto API // Generate secure password with crypto API
generatePassword() { generatePassword() {
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
if (!pwField || !enablePw) return; if (!pwField || !enablePw) return;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
@@ -559,13 +579,13 @@ const sharedView = {
async updateSharedItem() { async updateSharedItem() {
if (!this.currentItem) return; if (!this.currentItem) return;
const permRead = document.getElementById('sv-permission-read'); const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = document.getElementById('sv-permission-write'); const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = document.getElementById('sv-permission-reshare'); const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = document.getElementById('sv-enable-expiration'); const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = document.getElementById('sv-share-expiration'); const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const body = { const body = {
permissions: { permissions: {
@@ -588,10 +608,10 @@ const sharedView = {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Server error ${res.status}`); throw new Error(err.error || `Server error ${res.status}`);
} }
this.showNotification(this.translate('shared_itemUpdated', 'Share settings updated')); ui.showNotification(i18n.t('shared_itemUpdated', 'Share settings updated'), 'success');
} catch (err) { } catch (err) {
console.error('Error updating share:', err); console.error('Error updating share:', err);
this.showNotification(err.message || 'Error updating share', 'error'); ui.showNotification(/** @type {Error} */ (err).message || 'Error updating share', 'error');
} }
// update UI // update UI
ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true); ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true);
@@ -611,10 +631,10 @@ const sharedView = {
headers: this._headers() headers: this._headers()
}); });
if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`); if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`);
this.showNotification(this.translate('shared_itemRemoved', 'Share removed')); ui.showNotification(i18n.t('shared_itemRemoved', 'Share removed'), 'success');
} catch (err) { } catch (err) {
console.error('Error removing share:', err); console.error('Error removing share:', err);
this.showNotification('Error removing share', 'error'); ui.showNotification('Error removing share', 'error');
} }
this.closeShareDialog(); this.closeShareDialog();
@@ -627,13 +647,13 @@ const sharedView = {
// Send notification (stub) // Send notification (stub)
sendNotification() { sendNotification() {
if (!this.currentItem) return; if (!this.currentItem) return;
const emailEl = document.getElementById('sv-notification-email'); const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = document.getElementById('sv-notification-message'); const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
const email = emailEl ? emailEl.value.trim() : ''; const email = emailEl ? emailEl.value.trim() : '';
const message = msgEl ? msgEl.value.trim() : ''; const message = msgEl ? msgEl.value.trim() : '';
if (!email || !this.validateEmail(email)) { if (!email || !isEmailValid(email)) {
this.showNotification(this.translate('shared_invalidEmail', 'Please enter a valid email address'), 'error'); ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error');
return; return;
} }
@@ -642,30 +662,10 @@ const sharedView = {
.sendShareNotification(this.currentItem.url, email, message) .sendShareNotification(this.currentItem.url, email, message)
.then(() => { .then(() => {
this.closeNotificationDialog(); this.closeNotificationDialog();
this.showNotification(this.translate('shared_notificationSent', 'Notification sent')); ui.showNotification(i18n.t('shared_notificationSent', 'Notification sent'), 'success');
}) })
.catch(() => this.showNotification(this.translate('shared_notificationFailed', 'Failed to send notification'), 'error')); .catch(() => ui.showNotification(i18n.t('shared_notificationFailed', 'Failed to send notification'), 'error'));
} }
},
showNotification(message, type = 'success') {
if (ui?.showNotification) {
ui.showNotification(message, type);
} else {
alert(message);
}
},
validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
},
formatDate(value) {
return formatDateShort(value);
},
translate(key, defaultText) {
return i18n.t(key, defaultText);
} }
}; };