feat(people): add People tab frontend for face clusters
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
<link rel="stylesheet" href="/css/views/photos.css">
|
<link rel="stylesheet" href="/css/views/photos.css">
|
||||||
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
||||||
<link rel="stylesheet" href="/css/views/places.css">
|
<link rel="stylesheet" href="/css/views/places.css">
|
||||||
|
<link rel="stylesheet" href="/css/views/people.css">
|
||||||
<link rel="stylesheet" href="/css/views/music.css">
|
<link rel="stylesheet" href="/css/views/music.css">
|
||||||
|
|
||||||
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
<script defer type="module" src="/js/features/library/recent.js"></script>
|
<script defer type="module" src="/js/features/library/recent.js"></script>
|
||||||
<script defer type="module" src="/js/features/library/photos.js"></script>
|
<script defer type="module" src="/js/features/library/photos.js"></script>
|
||||||
<script defer type="module" src="/js/features/library/places.js"></script>
|
<script defer type="module" src="/js/features/library/places.js"></script>
|
||||||
|
<script defer type="module" src="/js/features/library/people.js"></script>
|
||||||
<script defer type="module" src="/js/features/library/music.js"></script>
|
<script defer type="module" src="/js/features/library/music.js"></script>
|
||||||
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
|
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
|
||||||
<script defer type="module" src="/js/model/recentModel.js"></script>
|
<script defer type="module" src="/js/model/recentModel.js"></script>
|
||||||
|
|||||||
@@ -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 = '<div class="people-loading"><i class="fas fa-spinner"></i></div>';
|
||||||
|
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 = '<div class="people-grid">';
|
||||||
|
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 += `<button class="person-tile" type="button" data-id="${this._escAttr(p.id)}" data-name="${this._escAttr(name)}">`;
|
||||||
|
html += `<span class="person-avatar" style="background-image:url(${cover})"></span>`;
|
||||||
|
html += `<span class="person-name">${this._escHtml(name)}</span>`;
|
||||||
|
html += `<span class="person-count">${p.face_count}</span>`;
|
||||||
|
html += '</button>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
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 =
|
||||||
|
'<div class="people-toolbar">' +
|
||||||
|
`<button class="people-back" type="button" title="${this._escAttr(i18n.t('people.back'))}"><i class="fas fa-arrow-left"></i></button>` +
|
||||||
|
`<h2 class="people-title">${this._escHtml(name)}</h2>` +
|
||||||
|
`<button class="people-rename" type="button" title="${this._escAttr(i18n.t('people.rename_title'))}"><i class="fas fa-pen"></i></button>` +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="photos-grid" id="person-photos"></div>';
|
||||||
|
/** @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 += `<div class="photo-tile" data-idx="${i}"><img src="/api/files/${this._escAttr(id)}/thumbnail/preview" loading="lazy" decoding="async" alt=""></div>`;
|
||||||
|
});
|
||||||
|
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 = `<div class="people-empty"><i class="fas fa-user-group"></i><p>${this._escHtml(text)}</p></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @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(/</g, '<');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { peopleView } from './people.js';
|
||||||
import { photosView } from './photos.js';
|
import { photosView } from './photos.js';
|
||||||
import { photosLightbox } from './photosLightbox.js';
|
import { photosLightbox } from './photosLightbox.js';
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export const placesView = {
|
|||||||
_libs: null,
|
_libs: null,
|
||||||
/** @type {number} debounce timer for moveend refresh */
|
/** @type {number} debounce timer for moveend refresh */
|
||||||
_moveTimer: 0,
|
_moveTimer: 0,
|
||||||
/** @type {'moments'|'places'} */
|
/** @type {'moments'|'places'|'people'} */
|
||||||
_activeTab: 'moments',
|
_activeTab: 'moments',
|
||||||
/** @type {boolean|null} cached basemap availability */
|
/** @type {boolean|null} cached basemap availability */
|
||||||
_hasBasemap: null,
|
_hasBasemap: null,
|
||||||
@@ -60,13 +61,15 @@ export const placesView = {
|
|||||||
bar.className = 'photos-subnav';
|
bar.className = 'photos-subnav';
|
||||||
bar.innerHTML =
|
bar.innerHTML =
|
||||||
`<button class="photos-subnav-tab active" type="button" data-ptab="moments">${this._esc(i18n.t('photos.tab_moments'))}</button>` +
|
`<button class="photos-subnav-tab active" type="button" data-ptab="moments">${this._esc(i18n.t('photos.tab_moments'))}</button>` +
|
||||||
`<button class="photos-subnav-tab" type="button" data-ptab="places">${this._esc(i18n.t('photos.tab_places'))}</button>`;
|
`<button class="photos-subnav-tab" type="button" data-ptab="places">${this._esc(i18n.t('photos.tab_places'))}</button>` +
|
||||||
|
`<button class="photos-subnav-tab hidden" type="button" data-ptab="people">${this._esc(i18n.t('photos.tab_people'))}</button>`;
|
||||||
bar.addEventListener('click', (e) => {
|
bar.addEventListener('click', (e) => {
|
||||||
const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]');
|
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);
|
contentArea.insertBefore(bar, contentArea.firstChild);
|
||||||
this._subnav = bar;
|
this._subnav = bar;
|
||||||
|
this._probePeople();
|
||||||
}
|
}
|
||||||
this._subnav.classList.remove('hidden');
|
this._subnav.classList.remove('hidden');
|
||||||
|
|
||||||
@@ -82,12 +85,26 @@ export const placesView = {
|
|||||||
this._activeTab = 'moments';
|
this._activeTab = 'moments';
|
||||||
this._setActiveTab('moments');
|
this._setActiveTab('moments');
|
||||||
this.hide();
|
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). */
|
/** Hide the tab bar and the map (called when leaving the Photos section). */
|
||||||
unmountTabs() {
|
unmountTabs() {
|
||||||
this._subnav?.classList.add('hidden');
|
this._subnav?.classList.add('hidden');
|
||||||
this.hide();
|
this.hide();
|
||||||
|
peopleView.hide();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Hide the map container (without destroying the map). */
|
/** 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) {
|
_switchTab(tab) {
|
||||||
if (tab === this._activeTab) return;
|
if (tab === this._activeTab) return;
|
||||||
this._activeTab = tab;
|
this._activeTab = tab;
|
||||||
this._setActiveTab(tab);
|
this._setActiveTab(tab);
|
||||||
if (tab === 'places') {
|
// Hide all three views, then show the selected one.
|
||||||
photosView.hide();
|
photosView.hide();
|
||||||
this._showMap();
|
this.hide();
|
||||||
} else {
|
peopleView.hide();
|
||||||
this.hide();
|
if (tab === 'places') this._showMap();
|
||||||
photosView.show();
|
else if (tab === 'people') peopleView.show();
|
||||||
}
|
else photosView.show();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** @param {string} tab */
|
/** @param {string} tab */
|
||||||
|
|||||||
@@ -76,9 +76,18 @@
|
|||||||
"layout_justified": "Justified",
|
"layout_justified": "Justified",
|
||||||
"tab_moments": "Moments",
|
"tab_moments": "Moments",
|
||||||
"tab_places": "Places",
|
"tab_places": "Places",
|
||||||
|
"tab_people": "People",
|
||||||
"map_loading": "Loading map…",
|
"map_loading": "Loading map…",
|
||||||
"map_error": "Could not load the 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": {
|
"music": {
|
||||||
"create_playlist": "Create Playlist",
|
"create_playlist": "Create Playlist",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
|
|||||||
Reference in New Issue
Block a user