feat(photos): justified (aspect-preserving) layout option

Adds a Grid/Justified toggle to the photos toolbar. Justified mode packs
tiles into Flickr-style rows scaled to the container width using each
photo's real aspect ratio (from the new /api/photos width/height, falling
back to 1:1 when missing). It composes with the virtualized renderer:
per-group materialization and the off-screen spacer height estimates are
both layout-aware. The choice persists in localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
Claude
2026-06-19 10:21:00 +00:00
parent 8d09589588
commit 75ee9b7cc4
3 changed files with 147 additions and 12 deletions
+20
View File
@@ -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));
+124 -11
View File
@@ -46,6 +46,8 @@ const photosView = {
_initialized: false,
/** @type {PhotoModeEnum} */
groupMode: 'monthly',
/** @type {'square'|'justified'} */
layoutMode: 'square',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
/** @type {Map<string, PhotoGroup>} 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 = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}" tabindex="0" role="button" aria-label="${this._escAttr(file.name)}">`;
const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : '';
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}" tabindex="0" role="button" aria-label="${this._escAttr(file.name)}"${styleAttr}>`;
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
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 += `<div class="photos-jrow" style="height:${row.height}px">`;
for (const t of row.tiles) {
html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`);
}
html += '</div>';
}
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 = '<div class="photos-toolbar"><div class="view-toggle">';
let html = '<div class="photos-toolbar">';
// Layout toggle (square crop ↔ justified rows)
html += '<div class="view-toggle photos-layout-toggle">';
html += `<button class="toggle-btn${this.layoutMode === 'square' ? ' active' : ''}" data-layout-mode="square" title="${this._escAttr(i18n.t('photos.layout_square'))}" aria-label="${this._escAttr(i18n.t('photos.layout_square'))}"><i class="fas fa-table-cells"></i></button>`;
html += `<button class="toggle-btn${this.layoutMode === 'justified' ? ' active' : ''}" data-layout-mode="justified" title="${this._escAttr(i18n.t('photos.layout_justified'))}" aria-label="${this._escAttr(i18n.t('photos.layout_justified'))}"><i class="fas fa-grip"></i></button>`;
html += '</div>';
// Grouping toggle (day / month / year)
html += '<div class="view-toggle">';
for (const [mode, label] of modes) {
const active = this.groupMode === mode ? ' active' : '';
html += `<button class="toggle-btn${active}" data-group-mode="${mode}">${this._escHtml(label)}</button>`;
@@ -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;
+3 -1
View File
@@ -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",