feat(ui): chat frontend, model picker, streaming, admin, a11y

Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-13 13:42:33 +00:00
co-authored by Claude Opus 5.5
parent a8c147b238
commit 8fda203d28
26 changed files with 571 additions and 144 deletions
+97 -7
View File
@@ -1278,6 +1278,7 @@ function _renderEditorLanding() {
<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-import"><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;"><path d="M12 3v12"/><path d="M7 8l5-5 5 5"/><path d="M5 21h14a2 2 0 0 0 2-2v-4"/><path d="M3 15v4a2 2 0 0 0 2 2"/></svg>Import</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>
<section class="gallery-editor-template-picker" aria-labelledby="gallery-editor-template-title">
@@ -1328,6 +1329,28 @@ function _renderEditorLanding() {
document.getElementById('gallery-editor-pick')?.addEventListener('click', () => {
document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
});
document.getElementById('gallery-editor-import')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const name = (file.name || 'Imported image').replace(/\.[^.]+$/, '') || 'Imported image';
const reader = new FileReader();
reader.onload = async () => {
try {
await openEditor(reader.result, null, null, name);
} catch (e) {
console.error('[gallery] image import failed', e);
uiModule?.showError?.('Could not import that image');
}
};
reader.onerror = () => uiModule?.showError?.('Could not read that file');
reader.readAsDataURL(file);
});
input.click();
});
}
// Wire the first-tile Upload affordance in the Photos grid. Opens the same
@@ -1351,6 +1374,11 @@ function _wireUploadTile() {
});
input.click();
});
tile.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
e.preventDefault();
tile.click();
});
}
// Shimmer placeholder tiles shown while the FIRST page loads, so the grid
@@ -1376,7 +1404,7 @@ function _renderGrid() {
// tile in the Albums tab so the upload entry point is consistent across
// both grids.
const uploadTile = `
<div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos">
<div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos" tabindex="0" role="button" aria-label="Upload photos or videos">
<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>
@@ -1415,10 +1443,10 @@ function _renderGrid() {
_isVideoUrl(img.url) ? '<span class="gallery-card-badge">Video</span>' : '',
].filter(Boolean).join('');
html += `
<div class="gallery-card" data-id="${_esc(img.id)}">
<div class="gallery-card" data-id="${_esc(img.id)}" tabindex="0" role="button" aria-label="${_esc(labelText)}">
<span class="gallery-select-dot" style="display:none;"></span>
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite">&#9829;</button>
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download">
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite" aria-label="Favorite" aria-pressed="${favCls ? 'true' : 'false'}">&#9829;</button>
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download" aria-label="Download">
<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>
</button>
${_isVideoUrl(img.url)
@@ -1464,6 +1492,14 @@ function _renderGrid() {
const img = _items.find(i => i.id === card.dataset.id);
if (img) _openDetail(img);
});
card.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
e.preventDefault();
const selectBtn = document.getElementById('gallery-select-btn');
if (selectBtn && selectBtn.classList.contains('active')) return;
const img = _items.find(i => i.id === card.dataset.id);
if (img) _openDetail(img);
});
});
// Download buttons
@@ -1575,7 +1611,7 @@ function _openDetail(img) {
<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>
<div class="gallery-detail-menu-wrap">
<button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions">
<button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions" aria-haspopup="menu" aria-expanded="false">
<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>
@@ -1804,7 +1840,7 @@ function _openDetail(img) {
const menu = document.getElementById('gallery-detail-menu');
if (menuBtn && menu) {
// `.dropdown { display:none }` isn't tied to [hidden] — set inline display.
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; };
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; menuBtn.setAttribute('aria-expanded', show ? 'true' : 'false'); };
_setMenu(false);
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2235,7 +2271,7 @@ export function openGallery() {
<div class="modal-content gallery-modal-content">
<div class="modal-header">
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><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>Gallery <span id="gallery-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;margin-left:8px"></span></h4>
<button class="modal-close" id="gallery-close">&times;</button>
<button class="modal-close" id="gallery-close" aria-label="Close">&times;</button>
</div>
<div class="gallery-tabs">
<button class="gallery-tab active" data-tab="images">
@@ -2251,6 +2287,7 @@ export function openGallery() {
<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>
<span class="gallery-editor-tabs-extra" id="gallery-editor-tabs-extra"></span>
<button class="gallery-tab" data-tab="settings">
<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"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="gallery-tab-label">Settings</span>
@@ -2318,6 +2355,13 @@ export function openGallery() {
</button>
</div>
</div>
<div class="admin-card">
<h2>Image model</h2>
<p class="memory-desc doclib-desc">Model used for AI edits and generated content in the editor (Quick Edit). Shared with the Quick Edit model picker.</p>
<select id="gallery-image-model" style="margin-top:8px;width:100%;">
<option value="">Loading…</option>
</select>
</div>
</div>
</div>
</div>
@@ -2558,6 +2602,52 @@ export function openGallery() {
});
}
// ── Image model (Settings tab) ──
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
// so the two stay in sync.
const _GE_MODEL_KEY = 'ge-ai-command-model';
const imageModelSelect = document.getElementById('gallery-image-model');
if (imageModelSelect) {
const _fillImageModels = async () => {
try {
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
const data = r.ok ? await r.json() : null;
const models = (data && Array.isArray(data.models)) ? data.models : [];
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
imageModelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = 'Default (configured provider)';
imageModelSelect.appendChild(def);
for (const m of models) {
const opt = document.createElement('option');
opt.value = `${m.base_url || ''}::${m.id}`;
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
imageModelSelect.appendChild(opt);
}
imageModelSelect.value = saved;
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
} catch (e) {
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
}
};
imageModelSelect.addEventListener('change', () => {
const val = imageModelSelect.value || '';
try {
if (val) localStorage.setItem(_GE_MODEL_KEY, val);
else localStorage.removeItem(_GE_MODEL_KEY);
} catch (e) {}
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
_fillImageModels();
}
window.addEventListener('storage', (e) => {
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
imageModelSelect.value = e.newValue || '';
}
});
// ── Tag All Untagged ──
let _tagCancelRequested = false;
let _tagging = false;