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 = ``;
+ },
+
+ /** @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",