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
+57 -21
View File
@@ -12,8 +12,10 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js';
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
const favorites = {
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
_cache: new Map(),
/** Whether the initial fetch from the server has completed */
@@ -25,6 +27,10 @@ const favorites = {
return { ...getCsrfHeaders() };
},
/**
* @param {string} id
* @param {string} type
*/
_cacheKey(id, type) {
return `${type}:${id}`;
},
@@ -33,6 +39,7 @@ const favorites = {
* Replace the entire in-memory cache from an array of FavoriteItemDto
* objects (as returned by the batch endpoint). Avoids an extra
* GET /api/favorites round-trip.
* @param {any[]} items
*/
_replaceCacheFromResponse(items) {
this._cache.clear();
@@ -68,6 +75,7 @@ const favorites = {
return;
}
/** @type {FavoriteItem[]} */
const items = await response.json();
this._cache.clear();
for (const item of items) {
@@ -85,6 +93,8 @@ const favorites = {
/**
* Synchronous check used by ui.js to paint star icons.
* @param {string} id
* @param {string} type
*/
isFavorite(id, type) {
return this._cache.has(this._cacheKey(id, type));
@@ -92,6 +102,10 @@ const favorites = {
/**
* 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) {
try {
@@ -121,6 +135,8 @@ const favorites = {
/**
* Remove an item from favourites (server-first).
* @param {string} id
* @param {string} type
*/
async removeFromFavorites(id, type) {
try {
@@ -178,31 +194,51 @@ const favorites = {
return;
}
/** @type {FolderItem[]} */
const folders = [];
/** @type {FileItem[]} */
const files = [];
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') {
folders.push({
id: item.item_id,
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
modified_at: item.modified_at || item.created_at,
path: item.item_path || ''
});
folders.push(
// FIXME: better to grab the real values
/** @type {FolderItem} */ {
id: item.item_id,
name: item.item_name || item.item_id,
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 {
files.push({
id: item.item_id,
name: item.item_name || item.item_id,
folder_id: item.parent_id || '',
mime_type: item.item_mime_type,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
category: item.category,
size: item.item_size || 0,
size_formatted: item.size_formatted,
modified_at: item.modified_at || item.created_at,
path: item.item_path || ''
});
files.push(
// FIXME: better to grab the real values
/** @type {FileItem} */ {
id: item.item_id,
name: item.item_name || item.item_id,
folder_id: item.parent_id || '',
mime_type: item.item_mime_type,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
category: item.category,
size: item.item_size || 0,
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);
+164 -33
View File
@@ -6,17 +6,27 @@ import { oxiIcon } from '../../core/icons.js';
import { Modal } from '../../core/modal.js';
import { notifications } from '../../core/notifications.js';
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
/**
* OxiCloud - Music Library View
* Playlist management with track listings and audio player
*/
const musicView = {
/** @type {Playlist[]} */
playlists: [],
/** @type {Playlist | null} */
currentPlaylist: null,
/** @type {PlaylistItem[]} */
currentTracks: [],
loading: false,
/** @type {HTMLDivElement | null} */
_container: null,
_initialized: false,
selected: new Set(),
@@ -72,11 +82,11 @@ const musicView = {
if (!resp.ok) throw new Error('Failed to load playlists');
this.playlists = await resp.json();
this.playlists = /** @type {Playlist[]} */ (await resp.json());
this._renderPlaylists();
} catch (err) {
console.error('Music load error:', err);
this._showError(err.message);
this._showError(/** @type {Error} */ (err).message);
} finally {
this.loading = false;
this._showLoading(false);
@@ -209,7 +219,7 @@ const musicView = {
)
.join('');
listEl.querySelectorAll('.music-playlist-item').forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (listEl.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.addEventListener('click', () => {
const id = item.dataset.id;
this._selectPlaylist(id);
@@ -269,6 +279,11 @@ const musicView = {
}
},
/**
*
* @param {string} playlistId
* @returns
*/
async _selectPlaylist(playlistId) {
const playlist = this.playlists.find((p) => p.id === playlistId);
if (!playlist) return;
@@ -308,13 +323,18 @@ const musicView = {
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);
});
await this._loadPlaylistTracks(playlistId);
},
/**
*
* @param {string} playlistId
* @returns
*/
async _loadPlaylistTracks(playlistId) {
const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return;
@@ -333,7 +353,7 @@ const musicView = {
this._renderTracks();
} catch (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('')}
`;
trackListEl.querySelectorAll('.music-track').forEach((row) => {
/** @type {NodeListOf<HTMLDivElement>} */ (trackListEl.querySelectorAll('.music-track')).forEach((row) => {
row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx, 10);
// Toggle selection
@@ -449,6 +469,11 @@ const musicView = {
});
},
/**
*
* @param {number} idx
* @returns
*/
_playTrack(idx) {
if (!this.currentTracks[idx]) return;
@@ -487,8 +512,12 @@ const musicView = {
this._createPlaylist(name.trim());
},
/**
*
* @param {String} 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;
try {
const resp = await fetch('/api/playlists', {
@@ -519,7 +548,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
} finally {
@@ -542,7 +571,7 @@ const musicView = {
});
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;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
@@ -568,7 +597,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
} finally {
@@ -576,6 +605,11 @@ const musicView = {
}
},
/**
*
* @param {number|null} secs
* @returns
*/
_formatDuration(secs) {
if (!secs) return '-';
const mins = Math.floor(secs / 60);
@@ -583,11 +617,20 @@ const musicView = {
return `${mins}:${s.toString().padStart(2, '0')}`;
},
/**
*
* @param {string|null} str
* @returns
*/
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
/**
*
* @param {boolean} show
*/
_showLoading(show) {
const existing = this._container?.querySelector('.music-loading');
if (show && !existing) {
@@ -600,6 +643,11 @@ const musicView = {
}
},
/**
*
* @param {string} message
* @returns
*/
_showError(message) {
if (!this._container) return;
this._container.innerHTML = `
@@ -647,7 +695,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -690,7 +738,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: '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'));
const listEl = document.getElementById('music-picker-list');
const queryInput = document.getElementById('music-picker-query');
const addBtn = document.getElementById('music-picker-add-btn');
const queryInput = /** @type {HTMLInputElement} */ (document.getElementById('music-picker-query'));
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-picker-add-btn'));
const countEl = document.getElementById('music-picker-count');
const selectedIds = new Set();
@@ -765,6 +813,11 @@ const musicView = {
}
};
/**
*
* @param {FileItem[]} files
* @returns
*/
const renderFiles = (files) => {
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>`;
@@ -798,6 +851,7 @@ const musicView = {
};
// ── Debounced search ──
/** @type {ReturnType<typeof setTimeout> | null} */
let searchTimer = null;
queryInput.addEventListener('input', () => {
clearTimeout(searchTimer);
@@ -857,6 +911,12 @@ const musicView = {
fetchAudioFiles();
},
/**
*
* @param {string} _trackId
* @param {string} fileId
* @returns
*/
async _removeTrackFromPlaylist(_trackId, fileId) {
if (!this.currentPlaylist) return;
@@ -892,12 +952,18 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: '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) {
if (!this.currentPlaylist) return;
@@ -923,7 +989,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
await this._loadPlaylistTracks(this.currentPlaylist.id);
@@ -967,8 +1033,8 @@ const musicView = {
});
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
const userInput = dialog.querySelector('#music-share-user-input');
const writeInput = dialog.querySelector('#music-share-write-input');
const userInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-user-input'));
const writeInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-write-input'));
const userId = userInput.value.trim();
if (!userId) return;
@@ -997,7 +1063,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1006,6 +1072,11 @@ const musicView = {
this._loadSharesList(dialog);
},
/**
*
* @param {HTMLDivElement} dialog
* @returns
*/
async _loadSharesList(dialog) {
if (!this.currentPlaylist) return;
const body = dialog.querySelector('.music-shares-body');
@@ -1019,6 +1090,7 @@ const musicView = {
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load shares');
/** @type {Musicshare[]} */
const shares = await resp.json();
if (shares.length === 0) {
@@ -1040,16 +1112,22 @@ const musicView = {
body.querySelectorAll('.music-share-remove-btn').forEach((btn) => {
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;
await this._removeShare(userId, dialog);
});
});
} 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) {
if (!this.currentPlaylist) return;
@@ -1067,7 +1145,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1115,7 +1193,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1183,7 +1261,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: '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
*/
const musicPlayer = {
/** @type {HTMLAudioElement | null} */
audio: null,
/** @type {PlaylistItem[]} */
queue: [],
currentIndex: -1,
/** @type {PlaylistItem|null} */
currentTrack: null,
isPlaying: false,
volume: 0.7,
isMuted: false,
@@ -1306,7 +1389,7 @@ const musicPlayer = {
const shuffleBtn = document.getElementById('player-shuffle-btn');
const repeatBtn = document.getElementById('player-repeat-btn');
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 playlistBtn = document.getElementById('player-playlist-btn');
const closeQueueBtn = document.getElementById('player-close-queue-btn');
@@ -1338,7 +1421,8 @@ const musicPlayer = {
if (volumeInput) {
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');
},
/**
*
* @param {PlaylistItem[]} tracks
* @param {string} playlistName
*/
setQueue(tracks, playlistName = '') {
this.queue = [...tracks];
this.playlistName = playlistName;
this._updateQueueUI();
},
/**
*
* @param {number} index
* @returns
*/
playTrack(index) {
if (index < 0 || index >= this.queue.length) return;
@@ -1488,15 +1582,19 @@ const musicPlayer = {
}
},
/**
*
* @param {number} vol
*/
setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol));
this.audio.volume = this.volume;
this.isMuted = this.volume === 0;
this._updateVolumeIcon();
const input = document.getElementById('player-volume-input');
const input = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-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}`;
},
/**
*
* @param {PointerEvent} e
* @returns
*/
_seek(e) {
const bar = document.getElementById('player-progress-bar');
if (!bar) return;
@@ -1596,6 +1699,10 @@ const musicPlayer = {
this._updateUI();
},
/**
*
* @param {ErrorEvent} e
*/
_onError(e) {
console.error('Audio error:', e);
this.isPlaying = false;
@@ -1624,7 +1731,8 @@ const musicPlayer = {
if (oxiIcon) {
icon.outerHTML = oxiIcon(iconName, extraClass);
} 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) {
document.querySelectorAll('.music-track').forEach((row) => {
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-track')).forEach((row) => {
const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
@@ -1710,15 +1818,16 @@ const musicPlayer = {
)
.join('');
queueList.querySelectorAll('.player-queue-item').forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (queueList.querySelectorAll('.player-queue-item')).forEach((item) => {
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);
this.playTrack(idx);
});
});
queueList.querySelectorAll('.queue-item-remove').forEach((btn) => {
/** @type {NodeListOf<HTMLButtonElement>} */ (queueList.querySelectorAll('.queue-item-remove')).forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx, 10);
@@ -1727,6 +1836,10 @@ const musicPlayer = {
});
},
/**
*
* @param {number} idx
*/
_removeFromQueue(idx) {
if (idx === this.currentIndex) {
if (this.queue.length === 1) {
@@ -1754,6 +1867,10 @@ const musicPlayer = {
this._updateUI();
},
/**
*
* @param {boolean|undefined} [show]
*/
_toggleQueue(show) {
const queue = document.getElementById('player-queue');
if (queue) {
@@ -1765,6 +1882,11 @@ const musicPlayer = {
}
},
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatTime(secs) {
if (!secs || Number.isNaN(secs)) return '0:00';
const mins = Math.floor(secs / 60);
@@ -1772,10 +1894,19 @@ const musicPlayer = {
return `${mins}:${s.toString().padStart(2, '0')}`;
},
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatDuration(secs) {
return this._formatTime(secs);
},
/**
* @param {string|null} str
* @returns {String}
*/
_escapeHtml(str) {
if (!str) return '';
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 { photosLightbox } from './photosLightbox.js';
/** @import {FileInfo} from '../../core/types.js' */
/** @import {FileItem} from '../../core/types.js' */
/**
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
*/
const photosView = {
/** @type {Array} All loaded photo items */
/** @type {Array<FileItem>} All loaded photo items */
items: [],
/** @type {string|null} Cursor for next page */
nextCursor: null,
@@ -27,7 +31,7 @@ const photosView = {
_container: null,
/** @type {boolean} */
_initialized: false,
/** @type {'daily'|'monthly'|'yearly'} */
/** @type {PhotoModeEnum} */
groupMode: 'monthly',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
@@ -84,6 +88,11 @@ const photosView = {
},
/** Switch grouping mode */
/**
*
* @param {PhotoModeEnum} mode
* @returns
*/
setGroupMode(mode) {
if (this.groupMode === mode) return;
this.groupMode = mode;
@@ -112,6 +121,7 @@ const photosView = {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
/** @type {FileItem[]} */
const data = await res.json();
if (!data || data.length === 0) {
@@ -178,7 +188,9 @@ const photosView = {
/** Append-only render for infinite scroll — inserts only the items
* 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) {
if (!this._container) return;
this._destroyObserver();
@@ -227,7 +239,10 @@ const photosView = {
this._setupVideoThumbnails(startIndex);
},
/** Generate HTML for a single photo/video tile */
/**
* Generate HTML for a single photo/video tile
* @param {FileItem} file
*/
_renderTile(file) {
const isVideo = file.mime_type?.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : '';
@@ -266,7 +281,7 @@ const photosView = {
/** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */
_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;
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) {
const fileId = tile.dataset.id;
// 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 {
await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
@@ -335,7 +354,10 @@ const photosView = {
</div>`;
},
/** Group items by the current groupMode */
/**
* Group items by the current groupMode
* @param {FileItem[]} items
*/
_groupItems(items) {
const map = new Map();
for (const item of items) {
@@ -363,20 +385,24 @@ const photosView = {
return map;
},
/** Handle click on photo tile or toolbar */
/**
* Handle click on photo tile or toolbar
* @param {MouseEvent} e
*/
_handleClick(e) {
// 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) {
this.setGroupMode(modeBtn.dataset.groupMode);
this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
return;
}
const tile = e.target.closest('.photo-tile');
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return;
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 (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) {
if (this.selected.has(id)) {
this.selected.delete(id);
@@ -483,6 +513,7 @@ const photosView = {
if (bar) bar.style.display = 'none';
},
/** @param {boolean} show */
_showLoading(show) {
if (!this._container) return;
let loader = this._container.querySelector('.photos-loading');
@@ -503,12 +534,14 @@ const photosView = {
}
},
/** @param {any} s */
_escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
+39 -23
View File
@@ -6,8 +6,11 @@
import { getCsrfHeaders } from '../../core/csrf.js';
import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
export const photosLightbox = {
/** @type {Array} Items array reference */
/** @type {Array<FileItem>} Items array reference */
items: [],
/** @type {number} Current index */
index: -1,
@@ -15,14 +18,14 @@ export const photosLightbox = {
_overlay: null,
/** @type {string|null} Current blob URL to revoke */
_blobUrl: null,
/** @type {Function|null} */
/** @type {(ev: KeyboardEvent) => any|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,
/**
* Register the photosView reference (called from photos.js to avoid circular imports).
* @param {Object} pv
* @param {any} pv
*/
setPhotosView(pv) {
this._photosView = pv;
@@ -33,7 +36,11 @@ export const photosLightbox = {
return getCsrfHeaders();
},
/** Open lightbox at given index */
/**
* Open lightbox at given index
* @param {FileItem[]} items
* @param {number} index
*/
open(items, index) {
this.items = items;
this.index = index;
@@ -99,21 +106,21 @@ export const photosLightbox = {
this._overlay = el;
// Event listeners
el.querySelector('.lightbox-close').onclick = () => this.close();
el.querySelector('.lightbox-prev').onclick = () => this.prev();
el.querySelector('.lightbox-next').onclick = () => this.next();
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
// Click backdrop to close
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();
}
});
// Toolbar actions
el.querySelector('.lb-download').onclick = () => this._download();
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite();
el.querySelector('.lb-delete').onclick = () => this._delete();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
// Animate in
requestAnimationFrame(() => el.classList.add('active'));
@@ -144,8 +151,8 @@ export const photosLightbox = {
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
// Update nav button visibility
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden';
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden';
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
// Load content
this._revokeBlob();
@@ -176,7 +183,13 @@ export const photosLightbox = {
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) {
try {
const res = await fetch(`/api/files/${fileId}/metadata`, {
@@ -184,16 +197,18 @@ export const photosLightbox = {
headers: this._headers()
});
if (res.ok) {
const data = await res.json();
const metadata = /** @type {FileMetadata} */ (await res.json());
const parts = [dateStr];
if (sizeStr) parts.push(sizeStr);
if (data.camera_make || data.camera_model) {
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' '));
if (metadata.camera_make || metadata.camera_model) {
parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
}
if (data.width && data.height) {
parts.push(`${data.width}×${data.height}`);
if (metadata.width && metadata.height) {
parts.push(`${metadata.width}×${metadata.height}`);
}
metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
}
} catch (_err) {
// Non-critical, keep existing meta
@@ -220,7 +235,7 @@ export const photosLightbox = {
await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST',
credentials: 'include',
headers: this._headers(true)
headers: this._headers()
});
const btn = this._overlay.querySelector('.lb-favorite');
if (btn) {
@@ -254,11 +269,11 @@ export const photosLightbox = {
this.items.splice(this.index, 1);
if (this.items.length === 0) {
this.close();
if (this._photosView) this._photosView._render();
if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
} else {
if (this.index >= this.items.length) this.index = this.items.length - 1;
this._show();
if (this._photosView) this._photosView._render();
if (this._photosView) this._photosView._renderFull();
}
} catch (err) {
console.error('Delete failed:', err);
@@ -289,6 +304,7 @@ export const photosLightbox = {
}
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
+22 -4
View File
@@ -12,6 +12,8 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js';
/** @import {FileItem, FolderItem, ItemTypeEnum} from '../../core/types.js' */
const recent = {
/** Maximum items to request from the server */
MAX_RECENT_FILES: 20,
@@ -38,8 +40,9 @@ const recent = {
*/
setupEventListeners() {
document.addEventListener('file-accessed', (event) => {
if (event.detail?.file) {
const file = event.detail.file;
const e = /** @type {CustomEvent} */ (event);
if (e.detail?.file) {
const file = e.detail.file;
const itemType = file.item_type || 'file';
this._recordAccess(file.id, itemType);
}
@@ -48,6 +51,8 @@ const recent = {
/**
* Record an access event on the server.
* @param {string} itemId
* @param {ItemTypeEnum} itemType
*/
async _recordAccess(itemId, itemType) {
try {
@@ -120,8 +125,12 @@ const recent = {
`);
}
/** @type {FolderItem[]} */
const folders = [];
/** @type {FileItem[]} */
const files = [];
for (const item of recentItems) {
const isFolder = item.item_type === 'folder';
if (isFolder) {
@@ -130,7 +139,13 @@ const recent = {
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
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 {
files.push({
@@ -144,7 +159,10 @@ const recent = {
size: item.item_size || 0,
size_formatted: item.size_formatted,
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
});
}
}