diff --git a/static/css/views/photos.css b/static/css/views/photos.css index 43ab6418..06ed928e 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -19,6 +19,7 @@ display: flex; align-items: center; justify-content: flex-end; + gap: var(--space-3); padding: var(--space-2) var(--space-2) var(--space-1); } @@ -54,6 +55,25 @@ margin-bottom: var(--space-4); } +/* Justified (aspect-preserving) layout — the grid becomes a column of rows; + tile sizes are set inline by photos.js (see _justifiedRows). */ +.photos-layout-justified .photos-grid { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.photos-jrow { + display: flex; + flex-direction: row; + gap: var(--space-2); +} + +.photos-layout-justified .photo-tile { + aspect-ratio: auto; + flex: 0 0 auto; +} + /* Monthly mode — larger tiles, more breathing room */ .photos-group-monthly .photos-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 8a116a6d..83a0f3ef 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -46,6 +46,8 @@ const photosView = { _initialized: false, /** @type {PhotoModeEnum} */ groupMode: 'monthly', + /** @type {'square'|'justified'} */ + layoutMode: 'square', /** @type {Map} fileId → thumbnail URL (persists across re-renders) */ _videoThumbCache: new Map(), /** @type {Map} group label → group record (DOM + data) */ @@ -81,6 +83,7 @@ const photosView = { } if (!this._initialized) { this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly'; + this.layoutMode = /** @type {'square'|'justified'} */ (localStorage.getItem('oxicloud-photos-layout')) || 'square'; this._initialized = true; } }, @@ -124,6 +127,17 @@ const photosView = { this._renderFull(); }, + /** + * Switch tile layout (square crop vs justified aspect-preserving rows). + * @param {'square'|'justified'} mode + */ + setLayoutMode(mode) { + if (this.layoutMode === mode) return; + this.layoutMode = mode; + localStorage.setItem('oxicloud-photos-layout', mode); + this._renderFull(); + }, + /** Fetch a page of photos from the API */ async _loadPage() { if (this.loading || this.exhausted) return; @@ -193,6 +207,8 @@ const photosView = { this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); this._container.classList.add(`photos-group-${this.groupMode}`); + this._container.classList.remove('photos-layout-square', 'photos-layout-justified'); + this._container.classList.add(`photos-layout-${this.layoutMode}`); if (this.items.length === 0 && this.exhausted) { this._renderEmpty(); @@ -249,9 +265,14 @@ const photosView = { const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid')); if (grid) { if (existing.materialized) { - let tilesHtml = ''; - for (const file of files) tilesHtml += this._renderTile(file); - grid.insertAdjacentHTML('beforeend', tilesHtml); + if (this.layoutMode === 'justified') { + // Justified rows must repack against the whole group. + grid.innerHTML = this._renderGroupTiles(existing.files); + } else { + let tilesHtml = ''; + for (const file of files) tilesHtml += this._renderTile(file); + grid.insertAdjacentHTML('beforeend', tilesHtml); + } this._setupVideoThumbnails(grid); this._fadeInTiles(grid); } else { @@ -346,9 +367,7 @@ const photosView = { rec.materialized = true; const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); if (!grid) return; - let html = ''; - for (const file of rec.files) html += this._renderTile(file); - grid.innerHTML = html; + grid.innerHTML = this._renderGroupTiles(rec.files); grid.style.minHeight = ''; this._setupVideoThumbnails(grid); this._fadeInTiles(grid); @@ -372,8 +391,7 @@ const photosView = { * @returns {{cols: number, gap: number, tile: number}} */ _gridMetrics() { - const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); - const width = sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; + const width = this._gridWidth(); const mobile = window.matchMedia('(max-width: 768px)').matches; let min; let gap; @@ -397,6 +415,13 @@ const photosView = { * @returns {number} */ _estimateHeight(count) { + if (this.layoutMode === 'justified') { + const width = this._gridWidth(); + const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200; + const perRow = Math.max(1, Math.round(width / (target * 1.4))); + const rows = Math.max(1, Math.ceil(count / perRow)); + return Math.round(rows * target + (rows - 1) * 8); + } const { cols, gap, tile } = this._gridMetrics(); const rows = Math.max(1, Math.ceil(count / cols)); return Math.round(rows * tile + (rows - 1) * gap); @@ -433,13 +458,15 @@ const photosView = { /** * Generate HTML for a single photo/video tile * @param {FileItem} file + * @param {string} [sizeStyle] Inline `width:..;height:..` for justified rows. */ - _renderTile(file) { + _renderTile(file, sizeStyle) { const isVideo = file.mime_type?.startsWith('video/'); const selected = this.selected.has(file.id) ? ' selected' : ''; const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null; const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; - let h = `
`; + const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : ''; + let h = `
`; h += `
`; const srcset = cachedThumb ? '' @@ -450,6 +477,77 @@ const photosView = { return h; }, + /** + * Inner HTML for a group's grid in the current layout mode. + * @param {FileItem[]} files + * @returns {string} + */ + _renderGroupTiles(files) { + if (this.layoutMode !== 'justified') { + let html = ''; + for (const file of files) html += this._renderTile(file); + return html; + } + const rows = this._justifiedRows(files, this._gridWidth()); + let html = ''; + for (const row of rows) { + html += `
`; + for (const t of row.tiles) { + html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`); + } + html += '
'; + } + return html; + }, + + /** + * Pack files into justified rows (Flickr-style): each full row is scaled so + * it fills the container width while preserving every tile's aspect ratio. + * Missing dimensions fall back to a 1:1 aspect. + * @param {FileItem[]} files + * @param {number} width Available content width in px. + * @returns {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} + */ + _justifiedRows(files, width) { + const gap = 8; + const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200; + /** @type {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */ + const rows = []; + /** @type {Array<{file: FileItem, aspect: number}>} */ + let cur = []; + let aspectSum = 0; + for (const file of files) { + let aspect = file.width && file.height ? file.width / file.height : 1; + if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1; + aspect = Math.min(Math.max(aspect, 0.4), 3); + cur.push({ file, aspect }); + aspectSum += aspect; + const rowWidth = aspectSum * target + (cur.length - 1) * gap; + if (rowWidth >= width) { + const h = (width - (cur.length - 1) * gap) / aspectSum; + rows.push({ + height: Math.round(h), + tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * h)), h: Math.round(h) })) + }); + cur = []; + aspectSum = 0; + } + } + if (cur.length) { + rows.push({ + height: target, + tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * target)), h: target })) + }); + } + return rows; + }, + + /** Current grid content width in px (for layout / height estimates). */ + _gridWidth() { + const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); + return sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; + }, + /** * Fade tiles in as their thumbnails finish loading (kills the pop-in). * Idempotent — only wires images not already marked loaded. @@ -532,7 +630,16 @@ const photosView = { ['monthly', i18n.t('photos.view_monthly')], ['yearly', i18n.t('photos.view_yearly')] ]; - let html = '
'; + let html = '
'; + + // Layout toggle (square crop ↔ justified rows) + html += '
'; + html += ``; + html += ``; + html += '
'; + + // Grouping toggle (day / month / year) + html += '
'; for (const [mode, label] of modes) { const active = this.groupMode === mode ? ' active' : ''; html += ``; @@ -596,6 +703,12 @@ const photosView = { return; } + const layoutBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-layout-mode]')); + if (layoutBtn) { + this.setLayoutMode(/** @type {'square'|'justified'} */ (layoutBtn.dataset.layoutMode)); + return; + } + const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile')); if (!tile) return; diff --git a/static/locales/en.json b/static/locales/en.json index 13b40de5..32a0efca 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -71,7 +71,9 @@ "view_yearly": "Year", "delete_title": "Move to Trash", "delete_selected_confirm": "Move the selected items to Trash?", - "delete_one_confirm": "Move \"{{name}}\" to Trash?" + "delete_one_confirm": "Move \"{{name}}\" to Trash?", + "layout_square": "Grid", + "layout_justified": "Justified" }, "music": { "create_playlist": "Create Playlist",