/** * 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(/