From 6314fa6b1cb2b0e8c7265eeaa22c02ba6c286971 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:58:23 +0000 Subject: [PATCH] feat(people): add People tab frontend for face clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client side of Phase 2 (People). A new "People" tab in the Photos sub-navigation lists identity clusters from GET /api/people and drills into a person's photos using the existing photos lightbox. - people.js: peopleView with list/drill-in/rename, reusing .photos-grid tiles and photosLightbox; rename via Modal.prompt + PATCH /api/people/{id} - people.css: person grid, circular avatars, single-person header, loading/empty states — all design tokens, no raw colors - places.js: People tab wired into the Moments|Places sub-nav, revealed only when GET /api/people is reachable (capability probe); _switchTab now toggles three views - index.html: load people.css + people.js - en.json: photos.tab_people + people.* labels (other locales fall back to English via i18n) The tab stays hidden unless OXICLOUD_ENABLE_FACES is on (the API 404s otherwise), so this is inert by default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/people.css | 112 +++++++++++++++++ static/index.html | 2 + static/js/features/library/people.js | 177 +++++++++++++++++++++++++++ static/js/features/library/places.js | 39 ++++-- static/locales/en.json | 9 ++ 5 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 static/css/views/people.css create mode 100644 static/js/features/library/people.js diff --git a/static/css/views/people.css b/static/css/views/people.css new file mode 100644 index 00000000..4b1d435c --- /dev/null +++ b/static/css/views/people.css @@ -0,0 +1,112 @@ +/* People (faces) view */ +.people-container { + display: none; +} + +.people-container.active { + display: block; + padding: var(--space-2); +} + +/* Grid of person tiles */ +.people-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-4); + padding: var(--space-2); +} + +.person-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + background: none; + border: none; + cursor: pointer; + border-radius: var(--radius-lg); +} + +.person-tile:hover { + background: var(--color-bg-muted); +} + +.person-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--color-bg-muted); + border: 2px solid var(--color-border); +} + +.person-name { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--color-text); +} + +.person-count { + font-size: var(--text-xs); + color: var(--color-text-faint); +} + +/* Single-person header */ +.people-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2); +} + +.people-toolbar .people-title { + flex: 1; + margin: 0; + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + color: var(--color-text); +} + +.people-back, +.people-rename { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: none; + color: var(--color-text-subtle); + font-size: var(--text-base); + cursor: pointer; +} + +.people-back:hover, +.people-rename:hover { + background: var(--color-bg-muted); +} + +/* Loading / empty states */ +.people-loading, +.people-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-20) var(--space-5); + color: var(--color-text-faint); +} + +.people-empty i { + font-size: 48px; + color: var(--color-border-medium); +} + +.people-loading i { + animation: spin 1s linear infinite; +} diff --git a/static/index.html b/static/index.html index b2a37ff8..e1ed6fbf 100644 --- a/static/index.html +++ b/static/index.html @@ -37,6 +37,7 @@ + @@ -59,6 +60,7 @@ + diff --git a/static/js/features/library/people.js b/static/js/features/library/people.js new file mode 100644 index 00000000..8024c42a --- /dev/null +++ b/static/js/features/library/people.js @@ -0,0 +1,177 @@ +/** + * OxiCloud - People (faces) + * + * A grid of identity clusters from GET /api/people; clicking a person shows + * their photos (reusing the photos lightbox). Faces are detected + clustered + * server-side; this view is read-mostly (list, drill-in, rename). + * + * The feature is gated on OXICLOUD_ENABLE_FACES — when it is off the API 404s + * and the view shows a short "disabled" hint (and the Places/People sub-nav + * hides the People tab via a capability probe). + */ + +import { Modal } from '../../components/modal.js'; +import { getCsrfHeaders } from '../../core/csrf.js'; +import { i18n } from '../../core/i18n.js'; +import { photosLightbox } from './photosLightbox.js'; + +/** @import {FileItem} from '../../core/types.js' */ +/** @typedef {{id: string, name?: string, cover_file_id?: string, face_count: number, is_hidden: boolean}} PersonItem */ + +export const peopleView = { + /** @type {HTMLElement|null} */ + _container: null, + + _headers() { + return getCsrfHeaders(); + }, + + /** Ensure the container exists (sibling in .content-area). */ + _mount() { + const ca = document.querySelector('.content-area'); + if (!ca) return; + if (!this._container) { + const el = document.createElement('div'); + el.id = 'people-container'; + el.className = 'people-container'; + ca.appendChild(el); + this._container = el; + } + }, + + async show() { + this._mount(); + if (!this._container) return; + this._container.classList.add('active'); + await this._renderList(); + }, + + hide() { + this._container?.classList.remove('active'); + }, + + async _renderList() { + if (!this._container) return; + this._container.innerHTML = '
'; + try { + const res = await fetch('/api/people', { credentials: 'include', headers: this._headers() }); + if (!res.ok) { + this._renderHint(i18n.t('people.disabled')); + return; + } + /** @type {PersonItem[]} */ + const people = await res.json(); + if (!people.length) { + this._renderHint(i18n.t('people.empty')); + return; + } + let html = '
'; + for (const p of people) { + const cover = p.cover_file_id ? `/api/files/${p.cover_file_id}/thumbnail/icon` : ''; + const name = p.name || i18n.t('people.unnamed'); + html += `'; + } + html += '
'; + this._container.innerHTML = html; + this._container.querySelectorAll('.person-tile').forEach((t) => { + const el = /** @type {HTMLElement} */ (t); + el.addEventListener('click', () => this._openPerson(el.dataset.id || '', el.dataset.name || '')); + }); + } catch (err) { + console.error('People load failed:', err); + this._renderHint(i18n.t('people.disabled')); + } + }, + + /** + * @param {string} personId + * @param {string} name + */ + async _openPerson(personId, name) { + if (!this._container) return; + this._container.innerHTML = + '
' + + `` + + `

${this._escHtml(name)}

` + + `` + + '
' + + '
'; + /** @type {HTMLButtonElement} */ (this._container.querySelector('.people-back')).onclick = () => this._renderList(); + /** @type {HTMLButtonElement} */ (this._container.querySelector('.people-rename')).onclick = () => this._rename(personId, name); + + try { + const res = await fetch(`/api/people/${personId}/photos`, { credentials: 'include', headers: this._headers() }); + if (!res.ok) return; + /** @type {string[]} */ + const fileIds = await res.json(); + // Minimal FileItems so the lightbox can open them by id. + const items = fileIds.map( + (id) => + /** @type {FileItem} */ (/** @type {any} */ ({ id, name: '', mime_type: 'image/jpeg', created_at: 0, sort_date: 0, size_formatted: '' })) + ); + const grid = this._container.querySelector('#person-photos'); + if (!grid) return; + let html = ''; + fileIds.forEach((id, i) => { + html += `
`; + }); + grid.innerHTML = html; + grid.querySelectorAll('.photo-tile').forEach((t) => { + const el = /** @type {HTMLElement} */ (t); + el.addEventListener('click', () => photosLightbox.open(items, Number(el.dataset.idx))); + }); + } catch (err) { + console.error('Person photos failed:', err); + } + }, + + /** + * @param {string} personId + * @param {string} current + */ + async _rename(personId, current) { + const placeholder = i18n.t('people.unnamed'); + const value = current === placeholder ? '' : current; + const name = await Modal.prompt({ + title: i18n.t('people.rename_title'), + label: i18n.t('people.name_label'), + value + }); + if (name === null) return; + try { + await fetch(`/api/people/${personId}`, { + method: 'PATCH', + credentials: 'include', + headers: { ...this._headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name || null }) + }); + } catch (err) { + console.error('Rename failed:', err); + } + this._openPerson(personId, name || placeholder); + }, + + /** @param {string} text */ + _renderHint(text) { + if (!this._container) return; + this._container.innerHTML = `

${this._escHtml(text)}

`; + }, + + /** @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, '"') + .replace(/${this._esc(i18n.t('photos.tab_moments'))}` + - ``; + `` + + ``; bar.addEventListener('click', (e) => { const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]'); - if (btn) this._switchTab(/** @type {'moments'|'places'} */ (btn.getAttribute('data-ptab'))); + if (btn) this._switchTab(/** @type {'moments'|'places'|'people'} */ (btn.getAttribute('data-ptab'))); }); contentArea.insertBefore(bar, contentArea.firstChild); this._subnav = bar; + this._probePeople(); } this._subnav.classList.remove('hidden'); @@ -82,12 +85,26 @@ export const placesView = { this._activeTab = 'moments'; this._setActiveTab('moments'); this.hide(); + peopleView.hide(); + }, + + /** Reveal the People tab only if GET /api/people is available (faces on). */ + async _probePeople() { + try { + const res = await fetch('/api/people', { credentials: 'include', headers: getCsrfHeaders() }); + if (res.ok) { + this._subnav?.querySelector('[data-ptab="people"]')?.classList.remove('hidden'); + } + } catch { + /* leave the People tab hidden */ + } }, /** Hide the tab bar and the map (called when leaving the Photos section). */ unmountTabs() { this._subnav?.classList.add('hidden'); this.hide(); + peopleView.hide(); }, /** Hide the map container (without destroying the map). */ @@ -96,19 +113,19 @@ export const placesView = { }, /** - * @param {'moments'|'places'} tab + * @param {'moments'|'places'|'people'} tab */ _switchTab(tab) { if (tab === this._activeTab) return; this._activeTab = tab; this._setActiveTab(tab); - if (tab === 'places') { - photosView.hide(); - this._showMap(); - } else { - this.hide(); - photosView.show(); - } + // Hide all three views, then show the selected one. + photosView.hide(); + this.hide(); + peopleView.hide(); + if (tab === 'places') this._showMap(); + else if (tab === 'people') peopleView.show(); + else photosView.show(); }, /** @param {string} tab */ diff --git a/static/locales/en.json b/static/locales/en.json index ce783528..572a88a8 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -76,9 +76,18 @@ "layout_justified": "Justified", "tab_moments": "Moments", "tab_places": "Places", + "tab_people": "People", "map_loading": "Loading map…", "map_error": "Could not load the map" }, + "people": { + "unnamed": "Unnamed", + "empty": "No people yet", + "disabled": "Face recognition is disabled", + "rename_title": "Name this person", + "name_label": "Name", + "back": "Back" + }, "music": { "create_playlist": "Create Playlist", "playlists": "Playlists",