Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+520 -100
View File
@@ -2,17 +2,137 @@
* Gallery Module — photo backup + AI-generated image library.
*/
import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
import { loadPanel } from './panels.js?v=20260909movepicklayer1';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { topPortalZ } from './toolWindowZOrder.js';
import sessionModule from './sessions.js';
import fileHandlerModule from './fileHandler.js?v=20260909mobileattachmentedit1';
const API_BASE = window.location.origin;
const ACTIVE_EDITOR_SESSION_KEY = 'odysseus-gallery-active-editor-v1';
let _open = false;
let _galleryResizeHandler = null;
// ── Image editor, loaded on first use ──
// galleryEditor.js plus everything under js/editor/ is 54 modules / 576 KB.
// It used to be a static import here, so every page load paid for it even
// though most sessions never touch the Edit tab. The wrappers below keep the
// three call shapes the rest of this file already uses.
//
// closeEditor() and isEditorOpen() stay synchronous on purpose: if the module
// was never loaded there is no edit session to close, and none can be open.
let _editorMod = null;
let _editorLoading = false;
// The editor shell is mounted before the size prompt resolves. A cancellation
// can therefore finish while the Gallery loader's transient flag is stale;
// restore the visible landing from the DOM after the editor tears down.
if (!window.__galleryEditorSetupCancelHandler) {
window.__galleryEditorSetupCancelHandler = () => {
const container = document.getElementById('gallery-editor-container');
const tab = document.querySelector('#gallery-editor-tab.active');
if (container && tab && !container.querySelector('.gallery-editor')) {
_renderEditorLanding();
}
};
window.addEventListener('gallery-editor-setup-cancelled', window.__galleryEditorSetupCancelHandler);
}
async function _loadEditor() {
_editorLoading = true;
try {
_editorMod = await loadPanel('editor');
return _editorMod;
} finally {
_editorLoading = false;
}
}
async function openEditor(...args) {
let mod = _editorMod;
if (!mod) {
try {
mod = await _loadEditor();
} catch (e) {
// Previously unreachable — a static import either loaded or the whole
// page failed. Now it can fail on its own (offline before the panel was
// ever cached), so say so instead of doing nothing.
console.error('[gallery] image editor failed to load', e);
uiModule?.showError?.('Failed to load the image editor');
return;
}
}
return mod.openEditor(...args);
}
function closeEditor(...args) {
return _editorMod ? _editorMod.closeEditor(...args) : undefined;
}
// True while the module is still in flight as well — the gallery-close paths
// use this to refuse to tear the container down under an edit that is opening.
function isEditorOpen() {
return _editorLoading || (_editorMod ? _editorMod.isEditorOpen() : false);
}
function _readActiveEditorSession() {
try {
const raw = sessionStorage.getItem(ACTIVE_EDITOR_SESSION_KEY);
if (!raw) return null;
const value = JSON.parse(raw);
if (!value || (!value.draftId && !value.imageUrl && !value.imageId
&& !(Number(value.width) > 0 && Number(value.height) > 0))) return null;
return value;
} catch (_) {
return null;
}
}
// Gallery is normally lazy and transient. If an edit was active in this tab
// before refresh, restore that edit after the gallery shell has been mounted.
async function _restoreActiveEditorAfterRefresh() {
const saved = _readActiveEditorSession();
if (!saved) return;
try {
openGallery();
let editorTab = null;
// The gallery module is lazy-loaded during startup. Give its synchronous
// mount a few frames to complete before treating the restore as failed.
for (let attempt = 0; attempt < 12 && !editorTab; attempt += 1) {
editorTab = document.querySelector('#gallery-modal .gallery-tab[data-tab="editor"]');
if (!editorTab) await new Promise(resolve => requestAnimationFrame(resolve));
}
if (!editorTab) return;
editorTab.click();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await openEditor(
saved.draftId ? null : (saved.imageUrl || null),
saved.imageId || null,
saved.width > 0 && saved.height > 0 ? { w: saved.width, h: saved.height } : null,
saved.draftName || null,
saved.draftId || null,
);
} catch (error) {
console.warn('[gallery] active editor restore failed', error);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(_restoreActiveEditorAfterRefresh, 0), { once: true });
} else {
setTimeout(_restoreActiveEditorAfterRefresh, 0);
}
// Auto-refresh gallery when new image is generated
window.addEventListener('gallery-refresh', () => {
window.addEventListener('gallery-refresh', (e) => {
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
_sort = 'recent';
const sortSel = document.getElementById('gallery-sort');
if (sortSel) sortSel.value = 'recent';
}
if (_open) _fetchLibrary(false);
});
let _items = [];
@@ -33,7 +153,7 @@ let _activeModel = null;
let _activeAlbum = null;
let _galleryCascaded = false; // play the domino-in cascade once per open
let _favoritesOnly = false;
let _sort = 'shuffle';
let _sort = 'recent';
let _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
let _offset = 0;
// Page size — computed from the grid's visible area so taller / wider
@@ -114,6 +234,7 @@ async function _fetchLibrary(append) {
_renderGrid();
_renderTags(data.tags || []);
_renderModels(data.models || []);
_renderAlbums();
_renderStats();
} catch (e) {
console.error('Gallery fetch error:', e);
@@ -346,6 +467,10 @@ function _renderStats() {
if (el) el.textContent = `${_total} photo${_total !== 1 ? 's' : ''}`;
}
function _filterPill(label, value, attr) {
return `<span class="gallery-chip gallery-active-filter-pill" title="${_esc(label)}"><span>${_esc(value)}</span><button class="gallery-chip-clear" ${attr} aria-label="Clear ${_esc(label)} filter">&times;</button></span>`;
}
function _renderTags(tags) {
// The global "every tag in the gallery" chip row under the search is gone —
// it just piled up every user-added tag with no way to remove it. Filter by
@@ -381,15 +506,20 @@ function _renderAlbums() {
if (!_activeAlbum) {
fhtml += `<button class="gallery-chip gallery-chip-fav${_favoritesOnly ? ' active' : ''}" data-fav="true" title="Favorites">&#9829;</button>`;
}
if (_search) fhtml += _filterPill('Search', `Search: ${_search}`, 'data-clear="search"');
if (_activeModel) fhtml += _filterPill('Source', _activeModel, 'data-clear="model"');
_activeTags.forEach(t => {
fhtml += `<span class="gallery-chip gallery-chip-active-album" title="Filtered to tag — click × to remove"><span>#${_esc(t)}</span><button class="gallery-chip-clear" data-clear-tag="${_esc(t)}" aria-label="Remove tag filter">&times;</button></span>`;
fhtml += _filterPill('Tag', `#${t}`, `data-clear-tag="${_esc(t)}"`);
});
if (_activeAlbum) {
const a = _albums.find(x => x.id === _activeAlbum);
if (a) {
fhtml += `<span class="gallery-chip gallery-chip-active-album" title="Currently showing this album — click X to clear"><span>${_esc(a.name)}</span><button class="gallery-chip-clear" data-clear="album" aria-label="Clear album filter">&times;</button></span>`;
fhtml += _filterPill('Album', a.name, 'data-clear="album"');
}
}
if (_search || _activeTags.length || _activeModel || _activeAlbum || _favoritesOnly) {
fhtml += `<button class="gallery-chip gallery-clear-all-filters" data-clear="all" title="Clear every gallery filter">Clear all</button>`;
}
filterC.innerHTML = fhtml;
filterC.querySelector('.gallery-chip[data-album=""]')?.addEventListener('click', () => {
_favoritesOnly = false;
@@ -410,6 +540,36 @@ function _renderAlbums() {
_fetchLibrary(false);
_renderAlbums();
});
filterC.querySelector('.gallery-chip-clear[data-clear="search"]')?.addEventListener('click', (e) => {
e.stopPropagation();
_search = '';
const searchInput = document.getElementById('gallery-search');
if (searchInput) searchInput.value = '';
_fetchLibrary(false);
_renderAlbums();
});
filterC.querySelector('.gallery-chip-clear[data-clear="model"]')?.addEventListener('click', (e) => {
e.stopPropagation();
_activeModel = null;
const modelSel = document.getElementById('gallery-model-filter');
if (modelSel) modelSel.value = '';
_fetchLibrary(false);
_renderAlbums();
});
filterC.querySelector('.gallery-clear-all-filters')?.addEventListener('click', (e) => {
e.stopPropagation();
_search = '';
_activeTags = [];
_activeModel = null;
_activeAlbum = null;
_favoritesOnly = false;
const searchInput = document.getElementById('gallery-search');
const modelSel = document.getElementById('gallery-model-filter');
if (searchInput) searchInput.value = '';
if (modelSel) modelSel.value = '';
_fetchLibrary(false);
_renderAlbums();
});
filterC.querySelectorAll('.gallery-chip-clear[data-clear-tag]').forEach(x => {
x.addEventListener('click', (e) => {
e.stopPropagation();
@@ -452,7 +612,7 @@ function _ensureAlbumsToolbar(container) {
</div>
<button class="gallery-select-btn gallery-toolbar-action" id="gallery-albums-select-btn" title="Select for bulk actions" style="position:relative;top:2px;"><span style="position:relative;top:1px;">Select</span></button>
</div>
<div class="memory-bulk-bar hidden" id="gallery-albums-bulk-bar">
<div class="memory-bulk-bar gallery-selection-bar hidden" id="gallery-albums-bulk-bar">
<label class="memory-bulk-check-all" style="position:relative;top:-1px;"><input type="checkbox" id="gallery-albums-bulk-all"> All</label>
<span id="gallery-albums-bulk-count" style="position:relative;top:-1px;">0 selected</span>
<button class="memory-toolbar-btn" id="gallery-albums-bulk-delete" title="Delete selected" style="margin-left:auto;color:var(--color-error, #f44);position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
@@ -517,6 +677,22 @@ function _updateAlbumBulkCount() {
if (del) del.style.opacity = sel > 0 ? '1' : '0.5';
}
function _albumActionTiles() {
return `
<div class="gallery-card gallery-card-upload gallery-album-action-tile" id="gallery-albums-new">
<div class="gallery-card-upload-inner">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/><line x1="12" y1="12" x2="12" y2="18"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<div class="gallery-card-upload-label">New album</div>
</div>
</div>
<div class="gallery-card gallery-card-upload gallery-album-action-tile" id="gallery-albums-upload">
<div class="gallery-card-upload-inner">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="gallery-card-upload-label">Upload</div>
</div>
</div>`;
}
function _renderAlbumsGrid() {
const container = document.getElementById('gallery-albums-container');
if (!container) return;
@@ -525,11 +701,7 @@ function _renderAlbumsGrid() {
const albums = _filteredAlbums();
if (!_albums.length) {
wrap.innerHTML = `
<div class="gallery-albums-empty">
<p>No albums yet.</p>
<button class="gallery-select-btn" id="gallery-albums-new">+ New album</button>
</div>`;
wrap.innerHTML = `<div class="gallery-albums-grid">${_albumActionTiles()}</div>`;
_wireAlbumsEvents(wrap);
return;
}
@@ -543,26 +715,7 @@ function _renderAlbumsGrid() {
// visually compete with the selection dots and can't be accidentally
// toggled like real albums.
if (!_albumSelectMode) {
html += `
<div class="gallery-album-card gallery-album-card-add" id="gallery-albums-new">
<div class="gallery-album-cover">
<div class="gallery-album-placeholder">+</div>
</div>
<div class="gallery-album-info">
<div class="gallery-album-name">New album</div>
</div>
</div>
<div class="gallery-album-card gallery-album-card-add" id="gallery-albums-upload">
<div class="gallery-album-cover">
<div class="gallery-album-placeholder">
<svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
</div>
<div class="gallery-album-info">
<div class="gallery-album-name">Upload album</div>
<div class="gallery-album-count">Pick a folder</div>
</div>
</div>`;
html += _albumActionTiles();
}
albums.forEach(a => {
// Empty albums get the placeholder icon even if cover_url is set —
@@ -593,6 +746,7 @@ function _renderAlbumsGrid() {
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span>
<span>Rename</span>
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item-compact dropdown-item-danger" data-action="delete">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span>
<span>Delete</span>
@@ -860,6 +1014,11 @@ let _draftsCache = [];
let _draftsSearch = '';
let _draftsSelectMode = false;
let _draftsSelected = new Set();
let _draftsCascaded = false;
let _draftsView = (() => {
try { return localStorage.getItem('gallery-drafts-view') === 'list' ? 'list' : 'grid'; }
catch (_) { return 'grid'; }
})();
async function _renderEditorDrafts() {
const section = document.getElementById('gallery-editor-drafts');
@@ -893,6 +1052,11 @@ async function _renderEditorDrafts() {
const present = new Set(_draftsCache.map(d => d.id));
for (const id of [..._draftsSelected]) if (!present.has(id)) _draftsSelected.delete(id);
_draftsPaint();
if (!_draftsCascaded) {
_draftsCascaded = true;
grid.classList.add('gallery-drafts-just-opened');
setTimeout(() => grid.classList.remove('gallery-drafts-just-opened'), 900);
}
_draftsWireOnce();
}
@@ -901,6 +1065,12 @@ async function _renderEditorDrafts() {
function _draftsPaint() {
const grid = document.getElementById('gallery-editor-drafts-grid');
if (!grid) return;
grid.classList.toggle('list-view', _draftsView === 'list');
document.querySelectorAll('#gallery-editor-drafts-view [data-view]').forEach(btn => {
const active = btn.dataset.view === _draftsView;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active));
});
const q = _draftsSearch.trim().toLowerCase();
const filtered = _draftsCache.filter(d => {
if (!q) return true;
@@ -925,7 +1095,7 @@ function _draftsPaint() {
<div class="gallery-editor-draft-name">${_esc(d.name || 'Untitled')}</div>
<div class="gallery-editor-draft-meta">${_esc([dims, updated].filter(Boolean).join(' · '))}</div>
</div>
<button class="gallery-editor-draft-delete" data-draft-id="${_esc(d.id)}" title="Delete project" aria-label="Delete project">×</button>
<button class="gallery-editor-draft-delete" data-draft-id="${_esc(d.id)}" title="Delete project" aria-label="Delete project"><span class="gallery-editor-draft-delete-x">×</span><svg class="gallery-editor-draft-delete-trash" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
</div>`;
}).join('');
grid.querySelectorAll('.gallery-editor-draft-card').forEach(card => {
@@ -994,6 +1164,8 @@ function _draftsSyncBulkBar() {
all.checked = visible.length > 0 && selVis === visible.length;
all.indeterminate = selVis > 0 && selVis < visible.length;
}
const deleteBtn = document.getElementById('gallery-editor-drafts-bulk-delete');
if (deleteBtn) deleteBtn.disabled = _draftsSelected.size === 0;
}
let _draftsWired = false;
@@ -1004,6 +1176,13 @@ function _draftsWireOnce() {
_draftsSearch = e.target.value || '';
_draftsPaint();
});
document.querySelectorAll('#gallery-editor-drafts-view [data-view]').forEach(btn => {
btn.addEventListener('click', () => {
_draftsView = btn.dataset.view === 'list' ? 'list' : 'grid';
try { localStorage.setItem('gallery-drafts-view', _draftsView); } catch (_) {}
_draftsPaint();
});
});
document.getElementById('gallery-editor-drafts-select')?.addEventListener('click', () => {
_draftsSelectMode = !_draftsSelectMode;
if (!_draftsSelectMode) _draftsSelected.clear();
@@ -1064,9 +1243,6 @@ function _renderEditorLanding() {
// openEditor()/closeEditor() may have left the container hidden; the Edit
// tab is still active so make sure the landing is actually visible.
container.style.display = 'flex';
// Templates rendered as a native <select>. Browsers handle all the layout
// and styling natively — no custom flex grid, no clipping, no empty boxes.
// Picking an option fires `change` and goes straight into the editor.
const presets = [
{ w: 1024, h: 1024, label: 'Square HD — 1024 × 1024' },
{ w: 1920, h: 1080, label: 'Widescreen — 1920 × 1080' },
@@ -1077,36 +1253,52 @@ function _renderEditorLanding() {
{ w: 2550, h: 3300, label: 'Letter (300dpi) — 2550 × 3300' },
{ w: 3840, h: 2160, label: '4K — 3840 × 2160' },
];
const optionsHtml = presets
.map((p, i) => `<option value="${i}">${p.label}</option>`)
const templateButtonsHtml = presets
.map((p, i) => {
const ratio = p.w / p.h;
const maxW = 30;
const maxH = 18;
const shapeW = ratio >= maxW / maxH ? maxW : Math.max(9, Math.round(maxH * ratio));
const shapeH = ratio >= maxW / maxH ? Math.max(9, Math.round(maxW / ratio)) : maxH;
const x = Math.round((40 - shapeW) / 2);
const y = Math.round((28 - shapeH) / 2);
const [name, dimensions = `${p.w} × ${p.h}`] = p.label.split(' — ');
return `<button type="button" class="gallery-editor-template-option" data-template-index="${i}" title="${_esc(p.label)}">
<svg width="40" height="28" viewBox="0 0 40 28" fill="none" aria-hidden="true"><rect x="${x}" y="${y}" width="${shapeW}" height="${shapeH}" rx="1" stroke="currentColor" stroke-width="1.5"/></svg>
<span>${_esc(name)}</span><small>${_esc(dimensions)}</small>
</button>`;
})
.join('');
container.innerHTML = `
<div class="gallery-editor-landing">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
<h3>Image Editor <span class="ge-alpha-tag">Alpha</span></h3>
<div class="gallery-editor-heading">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
<h3>Image Editor <span class="ge-alpha-tag">Alpha</span></h3>
</div>
<p>Start a blank canvas, or open a photo from your gallery to edit it.</p>
<div class="gallery-editor-landing-actions">
<button class="gallery-select-btn" id="gallery-editor-new">New canvas...</button>
<button class="gallery-select-btn" id="gallery-editor-pick">Browse photos</button>
<button class="gallery-select-btn" id="gallery-editor-pick"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Browse gallery</button>
</div>
<label class="gallery-editor-template-label">
Or pick a template
<select class="gallery-editor-template-select" id="gallery-editor-template">
<option value="">Select a size…</option>
${optionsHtml}
</select>
</label>
<section class="gallery-editor-template-picker" aria-labelledby="gallery-editor-template-title">
<h4 id="gallery-editor-template-title">Pick template</h4>
<div class="gallery-editor-template-options">${templateButtonsHtml}</div>
</section>
<div class="gallery-editor-drafts" id="gallery-editor-drafts" hidden>
<div class="gallery-editor-drafts-header">
<h4 class="gallery-editor-drafts-title">Saved projects</h4>
<input type="search" class="gallery-editor-drafts-search" id="gallery-editor-drafts-search" placeholder="Search projects…" autocomplete="off" />
<button class="gallery-select-btn" id="gallery-editor-drafts-select" title="Toggle multi-select">Select</button>
<div class="gallery-drafts-view-toggle" id="gallery-editor-drafts-view" role="group" aria-label="Project view">
<button type="button" data-view="grid" class="active" title="Grid view" aria-label="Grid view" aria-pressed="true"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></button>
<button type="button" data-view="list" title="List view" aria-label="List view" aria-pressed="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg></button>
</div>
<button class="gallery-select-btn gallery-toolbar-action" id="gallery-editor-drafts-select" title="Toggle multi-select" style="position:relative;top:0;"><span>Select</span></button>
</div>
<div class="gallery-bulk-bar hidden" id="gallery-editor-drafts-bulk">
<label class="memory-bulk-check-all"><input type="checkbox" id="gallery-editor-drafts-select-all"> All</label>
<span class="gallery-bulk-count" id="gallery-editor-drafts-bulk-count">0 selected</span>
<button class="gallery-bulk-delete" id="gallery-editor-drafts-bulk-delete"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete selected</button>
<button class="memory-toolbar-btn" id="gallery-editor-drafts-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<div class="memory-bulk-bar gallery-selection-bar hidden" id="gallery-editor-drafts-bulk">
<label class="memory-bulk-check-all" style="position:relative;top:-1px;"><input type="checkbox" id="gallery-editor-drafts-select-all"> All</label>
<span id="gallery-editor-drafts-bulk-count" style="position:relative;top:0;">0 selected</span>
<button class="memory-toolbar-btn" id="gallery-editor-drafts-bulk-delete" style="color:var(--red);position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
<button class="memory-toolbar-btn" id="gallery-editor-drafts-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="gallery-editor-drafts-grid" id="gallery-editor-drafts-grid"></div>
</div>
@@ -1114,19 +1306,24 @@ function _renderEditorLanding() {
// Each remount of the editor landing rebuilds the drafts header
// markup, so the cached event listener references are stale. Reset.
_draftsWired = false;
_draftsCascaded = false;
_renderEditorDrafts();
document.getElementById('gallery-editor-template')?.addEventListener('change', (e) => {
const idx = parseInt(e.target.value, 10);
if (Number.isNaN(idx)) return;
const p = presets[idx];
if (p) openEditor(null, null, { w: p.w, h: p.h }, `${p.w}×${p.h}`);
document.querySelectorAll('.gallery-editor-template-option').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.templateIndex, 10);
if (Number.isNaN(idx)) return;
const p = presets[idx];
if (p) openEditor(null, null, { w: p.w, h: p.h }, `${p.w}×${p.h}`);
});
});
document.getElementById('gallery-editor-new')?.addEventListener('click', async () => {
// openEditor() now returns a Promise — it's async because the size
// prompt is a styled modal. Await it before checking whether the
// editor actually opened (the user may have cancelled).
await openEditor(null, null, null, 'New canvas');
if (!isEditorOpen()) _renderEditorLanding();
if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
_renderEditorLanding();
}
});
document.getElementById('gallery-editor-pick')?.addEventListener('click', () => {
document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
@@ -1143,8 +1340,14 @@ function _wireUploadTile() {
input.type = 'file';
input.accept = 'image/*,video/*';
input.multiple = true;
input.addEventListener('change', () => {
if (input.files.length) _bulkUpload([...input.files], _activeAlbum);
input.addEventListener('change', async () => {
if (!input.files.length) return;
const files = [];
for (const file of [...input.files]) {
const nextFile = await fileHandlerModule.cropForMobileUpload(file);
if (nextFile) files.push(nextFile);
}
if (files.length) _bulkUpload(files, _activeAlbum);
});
input.click();
});
@@ -1201,9 +1404,16 @@ function _renderGrid() {
.replace(/\.[^.]+$/, '') // drop extension
.replace(/[_-]+/g, ' ')
.trim();
const labelText = (img.prompt || '').trim() || fallbackName || 'Photo';
const labelText = (img.caption || '').trim() || (img.prompt || '').trim() || fallbackName || 'Photo';
const promptPreview = labelText.length > 60 ? labelText.substring(0, 58) + '...' : labelText;
const favCls = img.favorite ? ' gallery-fav-active' : '';
const sourceLabel = img.model === 'imported' ? 'Imported' : (img.model || 'Image');
const badges = [
img.favorite ? '<span class="gallery-card-badge gallery-card-badge-fav">&#9829;</span>' : '',
img.album_id ? '<span class="gallery-card-badge">Album</span>' : '',
img.ai_tags ? '<span class="gallery-card-badge">AI tags</span>' : '',
_isVideoUrl(img.url) ? '<span class="gallery-card-badge">Video</span>' : '',
].filter(Boolean).join('');
html += `
<div class="gallery-card" data-id="${_esc(img.id)}">
<span class="gallery-select-dot" style="display:none;"></span>
@@ -1217,10 +1427,13 @@ function _renderGrid() {
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</span>`
: `<img src="${_esc(img.url)}" alt="${_esc(img.prompt)}" loading="lazy" />`}
<div class="gallery-card-overlay" aria-hidden="true">
${badges ? `<span class="gallery-card-badges">${badges}</span>` : ''}
</div>
<div class="gallery-card-info">
<div class="gallery-card-prompt">${_esc(promptPreview)}</div>
<div class="gallery-card-meta">
${img.model ? `<span class="gallery-card-model">${_esc(img.model)}</span>` : ''}
${sourceLabel ? `<span class="gallery-card-model">${_esc(sourceLabel)}</span>` : ''}
<span class="gallery-card-date">${date}</span>
</div>
</div>
@@ -1329,6 +1542,10 @@ function _openDetail(img) {
const aiTags = img.ai_tags || '';
const dims = img.width && img.height ? `${img.width} x ${img.height}` : (img.size || 'Unknown');
const fileSize = img.file_size ? _humanSize(img.file_size) : '';
const fallbackName = (img.filename || '').replace(/^\d{4,}[_-]/, '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
const displayTitle = (img.prompt || img.caption || fallbackName || 'Photo').trim();
const sourceLabel = img.model === 'imported' ? 'Imported' : (img.model || 'Unknown');
const sessionLabel = img.session_name || (img.session_id ? 'Source chat' : '');
// "Edited" row: only show when updated_at is meaningfully later than
// created_at (>10s). Every photo bumps updated_at on insert via the
// ORM timestamp mixin, so the gap filters out the trivial case.
@@ -1344,11 +1561,16 @@ function _openDetail(img) {
detail.innerHTML = `
<div class="gallery-detail-header">
<button class="gallery-detail-back" id="gallery-detail-back">&larr; Back</button>
<div class="gallery-detail-title" title="${_esc(displayTitle)}">${_esc(displayTitle)}</div>
<div style="flex:1"></div>
<button class="gallery-detail-back" id="gallery-edit-direct-btn" title="Edit (E)" aria-label="Edit photo" style="display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
Edit
</button>
<button class="gallery-detail-back" id="gallery-download-btn" title="Download" aria-label="Download photo" style="display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="gallery-detail-back gallery-detail-fav-header${img.favorite ? ' active' : ''}" id="gallery-detail-fav-header" title="${img.favorite ? 'Unfavorite' : 'Favorite'}" aria-label="Favorite" aria-pressed="${img.favorite ? 'true' : 'false'}" style="display:inline-flex;align-items:center;justify-content:center;padding:4px 8px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="${img.favorite ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
</button>
@@ -1357,6 +1579,10 @@ function _openDetail(img) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
<div class="gallery-detail-menu dropdown" id="gallery-detail-menu" hidden>
<button class="dropdown-item-compact" id="gallery-chat-photo-btn">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/></svg></span>
${img.session_id ? 'Open chat' : 'Discuss in chat'}
</button>
<button class="dropdown-item-compact" id="gallery-fav-detail">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="${img.favorite ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></span>
${img.favorite ? 'Favorited' : 'Favorite'}
@@ -1365,14 +1591,11 @@ function _openDetail(img) {
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg></span>
${aiTags ? 'Clear AI tags' : 'AI Tag'}
</button>
<button class="dropdown-item-compact" id="gallery-download-btn">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
Download
</button>
${img.album_id ? `<button class="dropdown-item-compact" id="gallery-set-cover-btn">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
Set as album cover
</button>` : ''}
<div class="dropdown-divider"></div>
<button class="dropdown-item-compact dropdown-item-danger" id="gallery-delete-btn">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span>
Delete
@@ -1402,6 +1625,10 @@ function _openDetail(img) {
</button>
</div>
<div class="gallery-detail-sidebar">
<div class="gallery-detail-inspector-head">
<div class="gallery-detail-eyebrow">Inspector</div>
<div class="gallery-detail-inspector-title">${_esc(displayTitle)}</div>
</div>
<div class="gallery-detail-section">
<label>Name</label>
<div class="gallery-name-wrap">
@@ -1410,20 +1637,17 @@ function _openDetail(img) {
<svg class="gallery-name-enter" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>
</div>
</div>
${img.prompt && img.model !== 'imported' ? `<div class="gallery-detail-section"><label>Prompt</label><div class="gallery-detail-prompt">${_esc(img.prompt)}</div></div>` : ''}
<div class="gallery-detail-section gallery-detail-section-date">
<label>Date</label>
<div>${date}</div>
${img.caption ? `<div class="gallery-detail-section"><label>OCR Caption</label><div class="gallery-detail-prompt">${_esc(img.caption)}</div></div>` : ''}
${img.prompt && img.model !== 'imported' ? `<div class="gallery-detail-section gallery-detail-prompt-section"><label>Prompt</label><div class="gallery-detail-prompt">${_esc(img.prompt)}</div><div class="gallery-detail-mini-actions"><button class="gallery-detail-mini-btn" id="gallery-detail-copy-prompt"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy</button><button class="gallery-detail-mini-btn" id="gallery-detail-reuse-prompt"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Reuse in chat</button></div></div>` : ''}
<div class="gallery-detail-meta-grid">
<div class="gallery-detail-meta-card"><span>Date</span><strong>${date}</strong></div>
<div class="gallery-detail-meta-card"><span>Size</span><strong>${dims}${fileSize ? ` · ${fileSize}` : ''}</strong></div>
<div class="gallery-detail-meta-card"><span>Source</span><strong>${_esc(sourceLabel)}</strong></div>
${sessionLabel ? `<div class="gallery-detail-meta-card"><span>Session</span><strong>${img.session_id ? `<button class="gallery-detail-inline-link" id="gallery-detail-source-link">${_esc(sessionLabel)}</button>` : _esc(sessionLabel)}</strong></div>` : ''}
</div>
${editedHtml}
<div class="gallery-detail-section">
<label>Dimensions</label>
<div>${dims}${fileSize ? ` (${fileSize})` : ''}</div>
</div>
${img.camera ? `<div class="gallery-detail-section"><label>Camera</label><div>${_esc(img.camera)}</div></div>` : ''}
${img.gps ? `<div class="gallery-detail-section"><label>Location</label><div>${img.gps.lat}, ${img.gps.lng}</div></div>` : ''}
${img.model ? `<div class="gallery-detail-section"><label>Source</label><div>${_esc(img.model)}</div></div>` : ''}
${img.session_name ? `<div class="gallery-detail-section"><label>Session</label><div>${_esc(img.session_name)}</div></div>` : ''}
${aiTags ? `<div class="gallery-detail-section"><label>AI Tags</label><div class="gallery-ai-tags">${aiTags.split(',').map(t => t.trim()).filter(Boolean).map(t => `<button class="gallery-ai-chip gallery-aitag-chip" data-tag-filter="${_esc(t)}" title="AI-generated tag — click to filter to photos tagged “${_esc(t)}”"><span class="gallery-aitag-mark" aria-hidden="true">✦</span>${_esc(t)}</button>`).join('')}</div></div>` : ''}
<div class="gallery-detail-section">
<label>Tags</label>
@@ -1454,6 +1678,93 @@ function _openDetail(img) {
detail.style.display = 'none';
});
const _openSourceChat = async () => {
if (!img.session_id) return false;
closeGallery();
try {
await sessionModule.selectSession(img.session_id);
return true;
} catch (e) {
console.error('Open source chat failed:', e);
uiModule.showError && uiModule.showError('Could not open source chat');
return false;
}
};
document.getElementById('gallery-chat-photo-btn')?.addEventListener('click', async () => {
if (img.session_id) {
await _openSourceChat();
return;
}
try {
const dcRes = await fetch('/api/default-chat', { credentials: 'same-origin' });
const dc = await dcRes.json();
if (!dc?.endpoint_url || !dc?.model) {
uiModule.showError && uiModule.showError('Pick a chat model first');
return;
}
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
closeGallery();
const res = await fetch(img.url, { credentials: 'same-origin' });
if (!res.ok) throw new Error('image fetch ' + res.status);
const blob = await res.blob();
const name = img.filename || 'gallery-photo.jpg';
const file = new File([blob], name, { type: blob.type || 'image/jpeg' });
fileHandlerModule.addFiles([file]);
const input = document.getElementById('message');
if (input) {
input.value = 'Let’s discuss this photo.';
input.dispatchEvent(new Event('input'));
input.focus();
}
} catch (e) {
console.error('Discuss photo failed:', e);
uiModule.showError && uiModule.showError('Could not start photo chat');
}
});
document.getElementById('gallery-detail-source-link')?.addEventListener('click', _openSourceChat);
document.getElementById('gallery-detail-copy-prompt')?.addEventListener('click', async (event) => {
const text = img.prompt || '';
if (!text) return;
const button = event.currentTarget;
try {
// navigator.clipboard is unavailable on many HTTP/IP deployments.
// ui.copyToClipboard falls back to a transient textarea + execCommand.
await uiModule.copyToClipboard(text);
const original = button.innerHTML;
button.textContent = 'Copied';
button.disabled = true;
setTimeout(() => {
button.innerHTML = original;
button.disabled = false;
}, 1200);
} catch (_) {
uiModule.showError && uiModule.showError('Could not copy prompt');
}
});
document.getElementById('gallery-detail-reuse-prompt')?.addEventListener('click', async () => {
const input = document.getElementById('message');
if (!input || !img.prompt) return;
try {
const res = await fetch(img.url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`image fetch ${res.status}`);
const blob = await res.blob();
const file = new File([blob], img.filename || 'gallery-image.png', {
type: blob.type || 'image/png',
});
fileHandlerModule.addFiles([file]);
closeGallery();
input.value = img.prompt;
input.dispatchEvent(new Event('input'));
input.focus();
} catch (error) {
console.error('Reuse gallery image failed:', error);
uiModule.showError && uiModule.showError('Could not attach gallery image');
}
});
// Clickable tag chips — both AI Tags and User Tags. Clicking a chip
// closes the detail, sets the tag filter on the main grid, and
// re-fetches so the user sees other photos with that tag.
@@ -1888,9 +2199,22 @@ export function openGallery() {
Modals.restore('gallery-modal');
return;
}
if (_open) return;
if (_open) {
// A cancelled lazy-open or an external modal teardown can leave the
// module flag set after the DOM node has gone away. Do not let that stale
// flag permanently block opening or refresh restoration.
if (document.getElementById('gallery-modal')) return;
_open = false;
}
_open = true;
_galleryCascaded = false; // replay the domino-in cascade on each open
let _freshChatUpload = false;
try {
const ts = Number(localStorage.getItem('gallery-fresh-chat-upload') || '0');
_freshChatUpload = ts > 0 && Date.now() - ts < 5 * 60 * 1000;
if (_freshChatUpload) localStorage.removeItem('gallery-fresh-chat-upload');
} catch (_) {}
if (_freshChatUpload) _sort = 'recent';
// State is preserved across close/reopen — filters, album, sort, items,
// albums, people — so reopening the gallery feels instant. Use the search
// input or "All" chip to clear the active filter.
@@ -1923,7 +2247,7 @@ export function openGallery() {
<span class="gallery-tab-label">Albums</span>
</button>
<button class="gallery-tab" data-tab="editor" id="gallery-editor-tab">
<span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span>
<span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg></span>
<span class="gallery-tab-label">Edit</span>
<span class="gallery-tab-close" id="gallery-editor-tab-close" title="Close edit" aria-label="Close edit">×</span>
</button>
@@ -1952,17 +2276,18 @@ export function openGallery() {
<option value="">All sources</option>
</select>
<select class="gallery-sort" id="gallery-sort">
<option value="shuffle">Random</option>
<option value="recent">Recent</option>
<option value="oldest">Oldest</option>
<option value="shuffle"${_sort === 'shuffle' ? ' selected' : ''}>↭ Random order</option>
<option value="recent"${_sort === 'recent' ? ' selected' : ''}>↓ Newest first</option>
<option value="oldest"${_sort === 'oldest' ? ' selected' : ''}>↑ Oldest first</option>
</select>
<button class="gallery-select-btn gallery-toolbar-action" id="gallery-select-btn" title="Select for bulk actions"><span style="position:relative;top:1px;">Select</span></button>
<button class="gallery-select-btn gallery-toolbar-action" id="gallery-select-btn" title="Select for bulk actions" style="position:relative;top:0;"><span>Select</span></button>
</div>
<div class="gallery-album-chips" id="gallery-filter-chips" style="margin-top:0;"></div>
<div class="memory-bulk-bar hidden" id="gallery-bulk-bar" style="margin-bottom:4px;">
<div class="memory-bulk-bar gallery-selection-bar hidden" id="gallery-bulk-bar">
<label class="memory-bulk-check-all" style="position:relative;top:-1px;"><input type="checkbox" id="gallery-bulk-select-all"> All</label>
<span id="gallery-bulk-count" style="position:relative;top:-1px;">0 selected</span>
<button class="memory-toolbar-btn" id="gallery-bulk-actions" style="position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">▼</span></button>
<button class="memory-toolbar-btn" id="gallery-bulk-delete" style="color:var(--red);position:relative;top:-3px;" disabled><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
<button class="memory-toolbar-btn" id="gallery-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="gallery-tag-chips" id="gallery-tag-chips"></div>
@@ -2219,7 +2544,7 @@ export function openGallery() {
if (visionLink) {
visionLink.addEventListener('click', (e) => {
e.preventDefault();
import('./settings.js').then(m => {
import('./settings.js?v=20260909defaultmodelfix1').then(m => {
m.open('ai');
// The gallery modal gets a bumped z-index from modalManager; settings
// opens with its lower static z-index and lands BEHIND it. Raise it above.
@@ -2382,6 +2707,8 @@ export function openGallery() {
// the "N selected" count (the button is a dimmer 60% --fg by default).
const actions = document.getElementById('gallery-bulk-actions');
if (actions) actions.style.color = sel > 0 ? 'var(--fg)' : '';
const deleteBtn = document.getElementById('gallery-bulk-delete');
if (deleteBtn) deleteBtn.disabled = sel === 0;
}
function _setSelectMode(on) {
@@ -2405,6 +2732,26 @@ export function openGallery() {
function _exitSelectMode() { _setSelectMode(false); }
// Gallery has three bulk-select surfaces. Escape leaves whichever one is
// active before the app-level modal handler can close the Gallery itself.
window.__galleryCancelSelection = () => {
if (_selectMode) {
_exitSelectMode();
return true;
}
if (_albumSelectMode) {
_setAlbumSelectMode(false);
return true;
}
if (_draftsSelectMode) {
_draftsSelectMode = false;
_draftsSelected.clear();
_draftsPaint();
return true;
}
return false;
};
selectBtn.addEventListener('click', () => _setSelectMode(!_selectMode));
document.getElementById('gallery-bulk-cancel')?.addEventListener('click', () => _exitSelectMode());
@@ -2514,7 +2861,7 @@ export function openGallery() {
// shares the exact same dropdown style/behaviour.
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
function _showGalleryBulkMenu(anchor) {
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
// matches every other menu in the app. Positioned fixed at the button.
const dropdown = document.createElement('div');
@@ -2523,7 +2870,7 @@ export function openGallery() {
const left = Math.min(rect.left, window.innerWidth - 200);
// Inline the standard dropdown look so it renders correctly even where the
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
@@ -2533,8 +2880,8 @@ export function openGallery() {
{ label: 'Favorite', icon: _favIco, action: () => _bulkFavorite(_selectedIds()) },
{ label: 'Add tag…', icon: _tagIco, action: () => _bulkTag(_selectedIds()) },
{ label: 'Download', icon: _dlIco, action: () => _bulkDownload(_selectedIds()) },
{ label: 'Delete', icon: _delIco, danger: true, action: () => _bulkDelete(_selectedIds()) },
{ separator: true },
{ label: 'Delete', icon: _delIco, danger: true, action: () => _bulkDelete(_selectedIds()) },
{ label: 'Cancel', icon: _cancelIco, action: () => _exitSelectMode() },
];
for (const a of items) {
@@ -2548,17 +2895,11 @@ export function openGallery() {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
document.body.appendChild(dropdown);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
_bulkActionsBtn?.addEventListener('click', (e) => {
@@ -2567,10 +2908,13 @@ export function openGallery() {
// should close it. The outside-click handler explicitly skips clicks on
// the anchor, so the button itself has to do its own dismiss.
const existing = document.querySelector('.gallery-bulk-menu');
if (existing) { existing.remove(); return; }
if (existing) { dismissOrRemove(existing); return; }
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
_showGalleryBulkMenu(e.currentTarget);
});
document.getElementById('gallery-bulk-delete')?.addEventListener('click', () => {
_bulkDelete(_selectedIds());
});
async function _bulkDelete(ids) {
if (!ids.length) return;
@@ -2687,6 +3031,11 @@ export function openGallery() {
_escHandler = (e) => {
if (e.key === 'Escape') {
if (window.__galleryCancelSelection?.()) {
e.preventDefault();
e.stopImmediatePropagation();
return;
}
// While the image editor is visible, Escape is reserved for the
// editor (cancel transform/lasso/crop, dismiss size prompt, etc.).
// Don't close the gallery — users would lose their in-progress edit.
@@ -2699,6 +3048,10 @@ export function openGallery() {
editorContainer.querySelector('.gallery-editor')
);
if (editorVisible || isEditorOpen()) {
// The Gallery owns the earliest capture listener, so delegate to the
// editor before swallowing Escape. This lets an active transform
// cancel without allowing the surrounding Gallery modal to close.
try { window.__galleryEditorHandleEscape?.(); } catch {}
e.preventDefault();
e.stopImmediatePropagation();
return;
@@ -2753,7 +3106,72 @@ export function openGallery() {
// fetch fails or takes a moment, the cached view sticks around.
_fetchAlbums();
_fetchLibrary(false);
searchInput.focus();
// Desktop keeps the convenient type-to-search behavior. On mobile this
// immediately opened the keyboard and dropped the user into Search before
// they could browse the drawer.
if (window.innerWidth > 768) searchInput.focus();
}
function _showImagesTab() {
const modal = document.getElementById('gallery-modal');
if (!modal) return;
modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
modal.querySelector('.gallery-tab[data-tab="images"]')?.classList.add('active');
const imagesContainer = document.getElementById('gallery-images-container');
const albumsContainer = document.getElementById('gallery-albums-container');
const editorContainer = document.getElementById('gallery-editor-container');
const settingsContainer = document.getElementById('gallery-settings-container');
if (imagesContainer) imagesContainer.style.display = '';
if (albumsContainer) albumsContainer.style.display = 'none';
if (editorContainer) editorContainer.style.display = 'none';
if (settingsContainer) settingsContainer.style.display = 'none';
}
export async function openGalleryImage(imageId) {
if (!imageId) {
openGallery();
return;
}
openGallery();
_showImagesTab();
_search = '';
_activeTags = [];
_activeModel = null;
_activeAlbum = null;
_favoritesOnly = false;
_sort = 'recent';
const searchInput = document.getElementById('gallery-search');
if (searchInput) searchInput.value = '';
const sortSel = document.getElementById('gallery-sort');
if (sortSel) sortSel.value = 'recent';
const detail = document.getElementById('gallery-detail');
if (detail) detail.style.display = 'none';
try {
await _fetchLibrary(false);
let img = _items.find(i => String(i.id) === String(imageId));
if (!img) {
const res = await fetch(`${API_BASE}/api/gallery/${encodeURIComponent(imageId)}`, { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
img = data.image || data;
}
}
if (!img || !img.id) {
uiModule.showToast?.('Photo not found in gallery', 3000);
return;
}
_openDetail(img);
const card = document.querySelector(`.gallery-card[data-id="${CSS.escape(String(imageId))}"]`);
if (card) {
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
card.classList.add('gallery-card-focus');
setTimeout(() => card.classList.remove('gallery-card-focus'), 1600);
}
} catch (err) {
console.error('[gallery] open image failed', err);
uiModule.showToast?.('Could not open photo in gallery', 3000);
}
}
function _doCloseGallery() {
@@ -2789,6 +3207,7 @@ function _doCloseGallery() {
document.removeEventListener('keydown', _escHandler, true);
_escHandler = null;
}
delete window.__galleryCancelSelection;
const btn = document.getElementById('tool-gallery-btn');
if (btn) btn.classList.remove('active');
@@ -2828,6 +3247,7 @@ function _humanSize(bytes) {
const galleryModule = {
openGallery,
openGalleryImage,
closeGallery,
isGalleryOpen,
};