Merge main (Photos/People/Places + ReBAC) into the SvelteKit rewrite

Bring the feature-rich main branch into the frontend Svelte rewrite
(PR #478, base bcn/frontend-svelte-rewrite). main moved well ahead of the
PR's branch point (b8a0018): it added the Places photo-map and People
(faces) backends, photos enhancements, the ReBAC→role-grants migration,
load tests, and more.

Conflicts resolved (4 files):
- Dockerfile: combine the explicit --bin allowlist (defence-in-depth from
  main) with the SPA copy from the frontend build stage (PR).
- .github/workflows/ci.yml: keep the PR's Svelte frontend job
  (svelte-check + eslint + stylelint + prettier + vitest); the legacy
  static/-targeted tsc/locale/icon advisory steps don't fit the new
  working-directory: frontend job and svelte-check supersedes them.
- justfile: keep both the new fe-* / dev recipes (PR) and the load-* k6
  recipes (main).
- static/locales: keep the PR's symlink (-> ../frontend/static/locales);
  main's new photos/people locale keys are folded into the Svelte locale
  files alongside the ported views.

Backend (people/places/faces handlers, routes, DI, migrations) merged
cleanly. `cargo check --bins` passes. The new Places/People UI is not yet
in the Svelte app; that is ported in follow-up commits.
This commit is contained in:
Claude
2026-06-19 12:45:22 +00:00
126 changed files with 11677 additions and 947 deletions
+5
View File
@@ -0,0 +1,5 @@
# The vector basemap is large (tens of MB) and operator-provided — never
# commit it to the repo. Drop a Protomaps `.pmtiles` here as `basemap.pmtiles`
# and the existing static file server (tower-http ServeDir, Range-capable)
# will serve it to the Places map. See README.md.
*.pmtiles
+33
View File
@@ -0,0 +1,33 @@
# Places basemap (optional)
The **Places** photo map renders your geotagged photos as clusters. It works
out of the box **without** a basemap (clusters on a plain background). To get a
real street/terrain backdrop, drop a self-hosted vector basemap here — no
third-party tile API, fully offline.
## How it works (Approach "A")
OxiCloud already serves `static/` through `tower-http`'s `ServeDir`, which
honours **HTTP Range** requests. A [PMTiles](https://docs.protomaps.com/pmtiles/)
basemap is a *single file* read directly by the browser via Range — so the
basemap is just a static file the app already knows how to serve. No extra
backend, no tile server, no API keys.
## Enabling it
1. Get a Protomaps `.pmtiles` basemap (vector, ODbL OpenStreetMap data):
- Whole planet z0–15 (~120 GB) or a smaller global `z0-6` (~60 MB), or
- A **regional extract** (recommended — only the area you need, a few MB):
```sh
# one-time, downloads only your bounding box from the remote planet
pmtiles extract https://build.protomaps.com/<DATE>.pmtiles basemap.pmtiles \
--bbox=<west>,<south>,<east>,<north>
```
See https://docs.protomaps.com/basemaps/downloads
2. Place it here as **`static/basemaps/basemap.pmtiles`** (this path is
git-ignored on purpose — see `.gitignore`).
3. Reload the Places view. The map will pick it up automatically.
The bundled style is **label-light** (water / land / roads / buildings, no
text) so it needs no glyph/sprite assets. Attribution “© OpenStreetMap”
(ODbL) is shown automatically when a basemap is present.
+112
View File
@@ -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;
}
+31
View File
@@ -8,11 +8,18 @@
display: block;
}
/* Virtualized timeline: each date-group is a <section>; its grid is
materialized (tiles inserted) only while near the viewport — see photos.js. */
.photos-group {
display: block;
}
/* Toolbar with group mode toggle */
.photos-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-2) var(--space-2) var(--space-1);
}
@@ -48,6 +55,25 @@
margin-bottom: var(--space-4);
}
/* Justified (aspect-preserving) layout — the grid becomes a column of rows;
tile sizes are set inline by photos.js (see _justifiedRows). */
.photos-layout-justified .photos-grid {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.photos-jrow {
display: flex;
flex-direction: row;
gap: var(--space-2);
}
.photos-layout-justified .photo-tile {
aspect-ratio: auto;
flex: 0 0 auto;
}
/* Monthly mode — larger tiles, more breathing room */
.photos-group-monthly .photos-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
@@ -107,6 +133,11 @@
border-color: var(--color-border-medium);
}
.photo-tile:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.photo-tile:hover img {
transform: scale(1.03);
}
+39
View File
@@ -170,6 +170,45 @@
z-index: 10001;
}
/* EXIF info panel */
.lightbox-infopanel {
position: absolute;
top: 64px;
right: var(--space-4);
max-width: 320px;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-lightbox-btn-bg);
color: var(--color-lightbox-btn-text);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
z-index: 10001;
}
.lightbox-infopanel.hidden {
display: none;
}
.lb-info-row {
display: flex;
align-items: center;
gap: var(--space-2);
word-break: break-word;
}
.lb-info-row i {
width: 18px;
text-align: center;
opacity: 0.8;
}
/* Zoomed photo shows a grab cursor for panning */
.lightbox-content img.is-zoomed {
cursor: grab;
}
/* Responsive */
@media (max-width: 768px) {
.lightbox-nav {
+90
View File
@@ -0,0 +1,90 @@
/* Photos sub-navigation (Moments | Places) */
.photos-subnav {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-2) 0;
}
.photos-subnav.hidden {
display: none;
}
.photos-subnav-tab {
background: none;
border: none;
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text-faint);
cursor: pointer;
border-radius: var(--radius-md);
border-bottom: 2px solid transparent;
}
.photos-subnav-tab:hover {
color: var(--color-text);
}
.photos-subnav-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* Map view */
.places-container {
display: none;
}
.places-container.active {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
min-height: 360px;
padding: var(--space-2);
}
.places-map {
flex: 1 1 auto;
width: 100%;
border-radius: var(--radius-2xl);
overflow: hidden;
}
.places-loading,
.places-error {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--color-text-faint);
}
.places-loading i {
animation: spin 1s linear infinite;
}
/* Cluster markers — a circular photo thumbnail with a count badge */
.places-cluster {
background-size: cover;
background-position: center;
background-color: var(--color-bg-muted);
border-radius: 50%;
border: 2px solid var(--color-bg-surface);
box-shadow: 0 2px 8px var(--color-shadow-sm);
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
}
.places-cluster-count {
background: var(--color-accent);
color: var(--color-danger-text);
font-size: var(--text-2xs);
font-weight: var(--weight-bold);
line-height: 1;
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-full);
transform: translateY(35%);
}
+4
View File
@@ -36,6 +36,8 @@
<link rel="stylesheet" href="/css/views/trash.css">
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.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">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
@@ -57,6 +59,8 @@
<script defer type="module" src="/js/features/library/favorites.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/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/sharing/fileSharing.js"></script>
<script defer type="module" src="/js/model/recentModel.js"></script>
+4 -1
View File
@@ -10,6 +10,7 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
import { favorites } from '../features/library/favorites.js';
import { musicView } from '../features/library/music.js';
import { photosView } from '../features/library/photos.js';
import { placesView } from '../features/library/places.js';
import { grants } from '../model/grants.js';
import { favoritesView } from '../views/favorites/favoritesView.js';
import { mySharesView } from '../views/myShares/mySharesView.js';
@@ -225,9 +226,10 @@ function setCurrentSection(section) {
// Reset owner column — sections that need it re-enable it explicitly below.
ui.setOwnerColumnVisible(false);
// Hide photosView when switching to any other section
// Hide photosView (+ the Places sub-view) when switching to any other section
if (section !== 'photos' && photosView) {
photosView.hide();
placesView.unmountTabs();
}
// Hide musicView when switching to any other section
@@ -451,6 +453,7 @@ function switchToPhotosSection() {
if (photosView) {
photosView.show();
}
placesView.mountTabs();
if (batchToolbar) batchToolbar.clear();
}
+41
View File
@@ -436,6 +436,47 @@ const Modal = {
requestAnimationFrame(() => {
this.overlay.classList.add('active');
});
},
/**
* Confirmation dialog (replacement for window.confirm()).
* Built on openPanel, so it inherits the overlay, animation, focus-trap,
* Escape and click-outside handling.
* @param {Object} options
* @param {string} options.title
* @param {string} options.message
* @param {string} [options.confirmText]
* @param {string} [options.cancelText]
* @param {string} [options.icon] - Font Awesome class, default 'fa-circle-question'
* @returns {Promise<boolean>} true if confirmed, false otherwise
*/
confirmDialog({ title, message, confirmText = null, cancelText = null, icon = 'fa-circle-question' }) {
return new Promise((resolve) => {
if (!this.overlay) {
resolve(false);
return;
}
const content = document.createElement('p');
content.className = 'modal-confirm-message';
content.textContent = message;
let settled = false;
const done = (/** @type {boolean} */ value) => {
if (settled) return;
settled = true;
resolve(value);
};
this.openPanel({
title,
icon,
content,
confirmText: confirmText ?? i18n.t('actions.confirm'),
cancelText: cancelText ?? i18n.t('actions.cancel'),
onConfirm: () => done(true),
onCancel: () => done(false)
});
});
}
};
+1 -1
View File
@@ -454,7 +454,7 @@ class MySharesList {
);
menu.appendChild(this._menuSeparator());
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
for (const role of /** @type {('owner'|'editor'|'viewer')[]} */ (['owner', 'editor', 'viewer'])) {
const isCurrent = grant.role === role;
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => {
menu.remove();
+7 -4
View File
@@ -23,7 +23,7 @@ import { i18n } from '../core/i18n.js';
* @returns {'manage'|'edit'|'view'}
*/
function roleMod(role) {
if (role === 'admin') return 'manage';
if (role === 'owner') return 'manage';
if (role === 'editor') return 'edit';
return 'view';
}
@@ -31,14 +31,17 @@ function roleMod(role) {
/**
* Translate a role identifier into a localized human-readable label.
* Exported so callers that just want the label (e.g. context-menu rows)
* can reuse the same wording the chip uses.
* can reuse the same wording the chip uses. Unknown roles fall back to
* the raw role string — `commenter` and `contributor` exist server-side
* but aren't surfaced in the UI today, so they'll display as-is until a
* future UI exposure adds proper labels.
* @param {string} role
* @returns {string}
*/
export function roleLabel(role) {
/** @type {Record<string,string>} */
const m = {
admin: i18n.t('share.role.canManage', 'Can manage'),
owner: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
@@ -51,7 +54,7 @@ export function roleLabel(role) {
* @returns {string}
*/
function roleIcon(role) {
if (role === 'admin') return 'fa-crown';
if (role === 'owner') return 'fa-crown';
if (role === 'editor') return 'fa-pencil-alt';
return 'fa-eye';
}
+15 -16
View File
@@ -73,13 +73,6 @@ function _looksLikeEmail(q) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
}
/** Permissions that belong to each role (must mirror the Rust DTO). */
const ROLE_PERMISSIONS = {
viewer: ['read'],
editor: ['read', 'comment', 'create', 'update'],
admin: ['read', 'comment', 'create', 'update', 'share', 'delete']
};
/**
* Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated
* endpoint; returns `[]` on any failure so the autocomplete degrades to
@@ -107,14 +100,20 @@ async function _searchGroups(q) {
}
/**
* Derive the highest role a set of grants represents for one subject.
* Pick the displayed role for a member row. Server-side every Grant
* carries an explicit role since the cleanup PR, so this just reads it.
* The server may emit `commenter` or `contributor` (full enum), but the
* picker only exposes Viewer/Editor/Owner — collapse the two unexposed
* roles to the closest neighbour so the UI never renders an unknown
* option.
* @param {Grant[]} subjectGrants
* @returns {ShareRoleEnum}
*/
function _roleFromGrants(subjectGrants) {
const perms = new Set(subjectGrants.map((g) => g.permission));
if (perms.has('delete') || perms.has('share')) return 'admin';
if (perms.has('create') || perms.has('update')) return 'editor';
const role = subjectGrants[0]?.role;
if (role === 'owner' || role === 'editor' || role === 'viewer') return role;
if (role === 'commenter') return 'viewer';
if (role === 'contributor') return 'editor';
return 'viewer';
}
@@ -363,7 +362,7 @@ const shareModal = {
for (const [val, label] of [
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['admin', i18n.t('share.role.canManage', 'Can manage')]
['owner', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
@@ -606,7 +605,7 @@ const shareModal = {
granted_at: '',
granted_by: '',
subject: { type: subjectType, id: contact.id },
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
role: this._stagedRole,
resource: { type: this._itemType, id: this._item?.id ?? '' }
};
this._localMembers.push({
@@ -649,7 +648,7 @@ const shareModal = {
// matching the UX contract and the kebab-menu / role-select dropdown
// order. Renaming the labels from "Manager"/"Editor"/"Viewer" to
// "Can manage"/"Can edit"/"Can view" left this iteration order stale.
const groups = /** @type {ShareRoleEnum[]} */ (['admin', 'editor', 'viewer']);
const groups = /** @type {ShareRoleEnum[]} */ (['owner', 'editor', 'viewer']);
let memberIndex = 0;
for (const role of groups) {
@@ -663,7 +662,7 @@ const shareModal = {
header.className = 'smd-group-header';
const labelMap = {
admin: i18n.t('share.role.canManage', 'Can manage'),
owner: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
@@ -711,7 +710,7 @@ const shareModal = {
for (const [val, label] of [
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['admin', i18n.t('share.role.canManage', 'Can manage')]
['owner', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
+18 -1
View File
@@ -524,7 +524,24 @@ const OxiIcons = {
'M384 96c0-35.3 28.7-64 64-64s64 28.7 64 64l0 32c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32c0-70.7-57.3-128-128-128S320 25.3 320 96l0 64-160 0c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64l-32 0 0-64z'
],
adjust: [512, 'M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z']
adjust: [512, 'M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z'],
'clock-rotate-left': [
576,
'M288 64c106 0 192 86 192 192S394 448 288 448c-65.2 0-122.9-32.5-157.6-82.3-10.1-14.5-30.1-18-44.6-7.9s-18 30.1-7.9 44.6C124.1 468.6 201 512 288 512 429.4 512 544 397.4 544 256S429.4 0 288 0C202.3 0 126.5 42.1 80 106.7L80 80c0-17.7-14.3-32-32-32S16 62.3 16 80l0 112c0 17.7 14.3 32 32 32l24.6 0c.5 0 1 0 1.5 0l86 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-38.3 0C154.9 102.6 217 64 288 64zm24 88c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 104c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65 0-94.1z'
],
'puzzle-piece': [
512,
'M224 0c35.3 0 64 21.5 64 48 0 10.4-4.4 20-12 27.9-6.6 6.9-12 15.3-12 24.9 0 15 12.2 27.2 27.2 27.2l44.8 0c26.5 0 48 21.5 48 48l0 44.8c0 15 12.2 27.2 27.2 27.2 9.5 0 18-5.4 24.9-12 7.9-7.5 17.5-12 27.9-12 26.5 0 48 28.7 48 64s-21.5 64-48 64c-10.4 0-20.1-4.4-27.9-12-6.9-6.6-15.3-12-24.9-12-15 0-27.2 12.2-27.2 27.2L384 464c0 26.5-21.5 48-48 48l-56.8 0c-12.8 0-23.2-10.4-23.2-23.2 0-9.2 5.8-17.3 13.2-22.8 11.6-8.7 18.8-20.7 18.8-34 0-26.5-28.7-48-64-48s-64 21.5-64 48c0 13.3 7.2 25.3 18.8 34 7.4 5.5 13.2 13.5 13.2 22.8 0 12.8-10.4 23.2-23.2 23.2L48 512c-26.5 0-48-21.5-48-48L0 343.2c0-12.8 10.4-23.2 23.2-23.2 9.2 0 17.3 5.8 22.8 13.2 8.7 11.6 20.7 18.8 34 18.8 26.5 0 48-28.7 48-64s-21.5-64-48-64c-13.3 0-25.3 7.2-34 18.8-5.5 7.4-13.5 13.2-22.8 13.2-12.8 0-23.2-10.4-23.2-23.2L0 176c0-26.5 21.5-48 48-48l108.8 0c15 0 27.2-12.2 27.2-27.2 0-9.5-5.4-18-12-24.9-7.5-7.9-12-17.5-12-27.9 0-26.5 28.7-48 64-48z'
],
sync: [
512,
'M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z'
],
upload: [
448,
'M256 109.3L256 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-210.7-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l96-96c12.5-12.5 32.8-12.5 45.3 0l96 96c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L256 109.3zM224 400c44.2 0 80-35.8 80-80l80 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64l80 0c0 44.2 35.8 80 80 80zm144 24a24 24 0 1 0 0-48 24 24 0 1 0 0 48z'
]
};
// Icon CSS is now in /css/components/icons.css (loaded via main.css).
+24 -9
View File
@@ -45,6 +45,8 @@
* @property {number} size
* @property {string} size_formatted
* @property {number} sort_date
* @property {number} [width] original pixel width (photos timeline only)
* @property {number} [height] original pixel height (photos timeline only)
* @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match
* @property {string} content_hash raw BLAKE3 content hash, for dedup checks
* @property {string} [snippet] plain-text fragment around a content match (search results only)
@@ -302,21 +304,27 @@
* @property {String} id
*/
/**
* Server-side role enum — every grantable role the backend recognises.
* The share modal's UI picker only exposes a subset (see `ShareRoleEnum`);
* the wire format may carry any of these values on a Grant.
* @typedef {'viewer'|'commenter'|'contributor'|'editor'|'owner'} GrantRoleEnum
*/
/**
* @typedef {Object} Grant
* @property {string} id
* @property {string} granted_at - ISO-8601 datetime string.
* @property {string} granted_by
* @property {Subject} subject
* @property {PermissionTypeEnum} permission
* @property {GrantRoleEnum} role - Role-keyed grant. One Grant = one role
* assignment in `storage.role_grants`. The implied permission bundle
* is derived client-side from the same lookup table used by
* `Role::expand()` on the server (see `ROLE_PERMISSIONS` in shareModal).
* @property {Resource} resource
* @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry.
*/
/**
* Roles: `viewer`, `commenter`, `editor`, `manager`, `admin`
*/
/**
* Configuration for `ResourceListComponent`.
* @typedef {Object} ResourceListConfig
@@ -367,7 +375,7 @@
* @property {'user'|'group'|'token'|'external'} subject_type
* @property {string} subject_id
* @property {string} subject_display - Username (users) or share name (tokens).
* @property {'viewer'|'editor'|'admin'} role
* @property {GrantRoleEnum} role - Server-emitted role string. `commenter` and `contributor` are reserved for future UI exposure; today the share modal only renders `viewer`/`editor`/`owner` (see `ShareRoleEnum`).
* @property {string} granted_at - ISO-8601
* @property {string|null} [expires_at] - ISO-8601 or absent.
* @property {boolean} has_password - True when a token subject has a password set.
@@ -453,15 +461,22 @@
// ------------------- share modal
/**
* Share roles (DTO-layer sugar for the ReBAC permission sets).
* @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum
* Share-modal-exposed roles. The server's `Role` enum also includes
* `commenter` and `contributor` (see `OutgoingResourceGrant.role`); those
* are reserved for future UI exposure and are not offered as picker options
* today. The "Can manage" UI label maps to `owner`.
* @typedef {'viewer'|'editor'|'owner'} ShareRoleEnum
*/
/**
* One collaborator row in the share modal's People section.
* @typedef {Object} MemberEntry
* @property {Grant} grant - Representative grant (used for subject/resource info).
* @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
* @property {Grant[]} _grants - All grants for this subject on the resource. Post-pivot
* this is at most one entry (`storage.role_grants` UNIQUE on
* `(subject, resource)`); the array shape is preserved so the existing
* "revoke every grant on remove" loop in `_applyAll` still works
* without a special-case for empty / new entries.
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
+177
View File
@@ -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, '&quot;')
.replace(/</g, '&lt;');
}
};
+444 -96
View File
@@ -3,6 +3,7 @@
* Photo grid grouped by day/month/year, with infinite scroll and multi-select.
*/
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { thumbnail } from '../thumbnail.js';
@@ -14,6 +15,14 @@ import { photosLightbox } from './photosLightbox.js';
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
*/
/**
* @typedef {Object} PhotoGroup
* @property {string} label
* @property {FileItem[]} files
* @property {HTMLElement} section
* @property {boolean} materialized
*/
const photosView = {
/** @type {Array<FileItem>} All loaded photo items */
items: [],
@@ -25,18 +34,32 @@ const photosView = {
exhausted: false,
/** @type {Set<string>} Selected item IDs */
selected: new Set(),
/** @type {IntersectionObserver|null} */
_observer: null,
/** @type {IntersectionObserver|null} Materializes/dematerializes group tiles by viewport proximity */
_materializeObserver: null,
/** @type {IntersectionObserver|null} Infinite-scroll trigger on the sentinel */
_sentinelObserver: null,
/** @type {HTMLElement|null} */
_container: null,
/** @type {HTMLElement|null} The infinite-scroll sentinel element */
_sentinelEl: null,
/** @type {boolean} */
_initialized: false,
/** @type {PhotoModeEnum} */
groupMode: 'monthly',
/** @type {'square'|'justified'} */
layoutMode: 'square',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
/** @type {number} Items already rendered in the DOM */
_renderedCount: 0,
/** @type {Map<string, PhotoGroup>} group label → group record (DOM + data) */
_groupData: new Map(),
/** @type {string[]} Ordered group labels (timeline order) */
_groupOrder: [],
/** @type {(() => void)|null} Debounced window resize handler */
_resizeHandler: null,
/** @type {number} */
_resizeTimer: 0,
/** @type {string|null} Anchor id for shift-range selection */
_selectAnchorId: null,
PAGE_SIZE: 200,
@@ -60,6 +83,7 @@ const photosView = {
}
if (!this._initialized) {
this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly';
this.layoutMode = /** @type {'square'|'justified'} */ (localStorage.getItem('oxicloud-photos-layout')) || 'square';
this._initialized = true;
}
},
@@ -73,7 +97,9 @@ const photosView = {
this.nextCursor = null;
this.exhausted = false;
this.selected.clear();
this._renderedCount = 0;
this._groupData = new Map();
this._groupOrder = [];
this._destroyObserver();
this._container.innerHTML = '';
this._loadPage();
},
@@ -84,6 +110,7 @@ const photosView = {
this._container.classList.remove('active');
}
this._destroyObserver();
this._unbindResize();
this._hideSelectionBar();
},
@@ -97,7 +124,17 @@ const photosView = {
if (this.groupMode === mode) return;
this.groupMode = mode;
localStorage.setItem('oxicloud-photos-group', mode);
this._renderedCount = 0;
this._renderFull();
},
/**
* Switch tile layout (square crop vs justified aspect-preserving rows).
* @param {'square'|'justified'} mode
*/
setLayoutMode(mode) {
if (this.layoutMode === mode) return;
this.layoutMode = mode;
localStorage.setItem('oxicloud-photos-layout', mode);
this._renderFull();
},
@@ -149,18 +186,29 @@ const photosView = {
}
},
// ── Rendering ───────────────────────────────────────────────────
// Two render paths:
// _renderFull() — full DOM rebuild (first load, group-mode change, delete)
// _appendBatch(n) — append-only for infinite-scroll pages (O(batch))
// ── Virtualized rendering ───────────────────────────────────────
// The timeline can hold tens of thousands of items, so we never keep
// every tile in the DOM. Each date-group is a <section> with a header
// (always present, cheap) and a grid that is *materialized* (tiles in
// the DOM) only while near the viewport, and *dematerialized* (emptied,
// its height frozen as a spacer) once it scrolls far away. An
// IntersectionObserver rooted on the scroll container drives the swap,
// so the DOM node count stays bounded by a few screens regardless of
// library size.
// _renderFull() — rebuild the group skeleton (first load, mode switch, delete)
// _appendBatch(n) — append new groups for infinite-scroll pages
/** Full DOM rebuild — first load, group-mode switch, or after deletions. */
/** Rebuild the group skeleton — first load, group-mode switch, or deletions. */
_renderFull() {
if (!this._container) return;
this._destroyObserver();
this._groupData = new Map();
this._groupOrder = [];
this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly');
this._container.classList.add(`photos-group-${this.groupMode}`);
this._container.classList.remove('photos-layout-square', 'photos-layout-justified');
this._container.classList.add(`photos-layout-${this.layoutMode}`);
if (this.items.length === 0 && this.exhausted) {
this._renderEmpty();
@@ -168,89 +216,257 @@ const photosView = {
}
if (this.items.length === 0) return;
const groups = this._groupItems(this.items);
let html = this._renderToolbar();
// Toolbar via innerHTML, then append group <section>s + sentinel as
// real elements so we keep references for the observer.
this._container.innerHTML = this._renderToolbar();
this._container.onclick = (e) => this._handleClick(e);
this._container.onkeydown = (e) => this._handleKeydown(e);
const groups = this._groupItems(this.items);
for (const [label, files] of groups) {
html += `<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
html += '<div class="photos-grid">';
for (const file of files) html += this._renderTile(file);
html += '</div>';
/** @type {PhotoGroup} */
const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false };
this._groupData.set(label, rec);
this._groupOrder.push(label);
this._container.appendChild(rec.section);
}
html += '<div class="photos-sentinel"></div>';
this._container.innerHTML = html;
this._container.onclick = (e) => this._handleClick(e);
this._fadeInTiles();
this._renderedCount = this.items.length;
this._observeSentinel();
this._setupVideoThumbnails();
const sentinel = document.createElement('div');
sentinel.className = 'photos-sentinel';
this._container.appendChild(sentinel);
this._sentinelEl = sentinel;
this._setupObservers();
this._eagerMaterialize();
this._bindResize();
},
/** Append-only render for infinite scroll — inserts only the items
* from this.items[startIndex..] without destroying existing DOM.
* Complexity: O(batch) instead of O(total_items).
/** Append new groups for an infinite-scroll page without rebuilding the
* existing skeleton. The first new group may continue the previous tail
* label, in which case we merge into it. Complexity: O(new groups).
* @param {number} startIndex
*/
_appendBatch(startIndex) {
if (!this._container) return;
this._destroyObserver();
const newItems = this.items.slice(startIndex);
if (newItems.length === 0) {
this._observeSentinel();
return;
}
const newGroups = this._groupItems(newItems);
const sentinel = this._container.querySelector('.photos-sentinel');
if (!sentinel) {
// Fallback: sentinel missing — full rebuild
this._renderedCount = 0;
if (!this._container || !this._sentinelEl) {
this._renderFull();
return;
}
const newItems = this.items.slice(startIndex);
if (newItems.length === 0) return;
const newGroups = this._groupItems(newItems);
for (const [label, files] of newGroups) {
let tilesHtml = '';
for (const file of files) tilesHtml += this._renderTile(file);
// Does this date-group already exist in the DOM?
const existingHeader = this._container.querySelector(`.photos-day-header[data-group="${CSS.escape(label)}"]`);
if (existingHeader) {
// Append tiles to existing grid and update count badge
const grid = existingHeader.nextElementSibling;
if (grid?.classList.contains('photos-grid')) {
grid.insertAdjacentHTML('beforeend', tilesHtml);
const countSpan = existingHeader.querySelector('.photos-day-count');
if (countSpan) countSpan.textContent = String(grid.children.length);
const existing = this._groupData.get(label);
if (existing) {
// Continuation of a group already in the timeline.
existing.files = existing.files.concat(files);
const countEl = existing.section.querySelector('.photos-day-count');
if (countEl) countEl.textContent = String(existing.files.length);
const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid'));
if (grid) {
if (existing.materialized) {
if (this.layoutMode === 'justified') {
// Justified rows must repack against the whole group.
grid.innerHTML = this._renderGroupTiles(existing.files);
} else {
let tilesHtml = '';
for (const file of files) tilesHtml += this._renderTile(file);
grid.insertAdjacentHTML('beforeend', tilesHtml);
}
this._setupVideoThumbnails(grid);
this._fadeInTiles(grid);
} else {
grid.style.minHeight = `${this._estimateHeight(existing.files.length)}px`;
}
}
} else {
// New group — insert header + grid before sentinel
const sectionHtml =
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
`<div class="photos-grid">${tilesHtml}</div>`;
sentinel.insertAdjacentHTML('beforebegin', sectionHtml);
/** @type {PhotoGroup} */
const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false };
this._groupData.set(label, rec);
this._groupOrder.push(label);
this._container.insertBefore(rec.section, this._sentinelEl);
this._materializeObserver?.observe(rec.section);
}
}
},
this._renderedCount = this.items.length;
this._observeSentinel();
this._setupVideoThumbnails(startIndex);
this._fadeInTiles();
/** Build a dematerialized group section (header + empty grid spacer).
* @param {string} label
* @param {FileItem[]} files
* @returns {HTMLElement}
*/
_buildGroupEl(label, files) {
const section = document.createElement('section');
section.className = 'photos-group';
section.dataset.group = label;
section.innerHTML =
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
`<div class="photos-grid" style="min-height:${this._estimateHeight(files.length)}px"></div>`;
return section;
},
/** Wire the two IntersectionObservers (materialization + infinite scroll). */
_setupObservers() {
const root = this._container?.parentElement || null;
if (!('IntersectionObserver' in window)) {
// Degrade gracefully: render every group (legacy behaviour).
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (rec) this._materializeGroup(rec.section);
}
return;
}
this._materializeObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const section = /** @type {HTMLElement} */ (entry.target);
if (entry.isIntersecting) this._materializeGroup(section);
else this._dematerializeGroup(section);
}
},
{ root, rootMargin: '1200px 0px' }
);
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (rec) this._materializeObserver.observe(rec.section);
}
if (this._sentinelEl) {
this._sentinelObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) this._loadPage();
},
{ root, rootMargin: '600px 0px' }
);
this._sentinelObserver.observe(this._sentinelEl);
}
},
/** Synchronously materialize the first groups within ~1.5 viewports so
* the initial paint has tiles before the observer's first callback. */
_eagerMaterialize() {
const budget = (this._container?.parentElement?.clientHeight || window.innerHeight) * 1.5;
let acc = 0;
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (!rec) continue;
this._materializeGroup(rec.section);
acc += rec.section.offsetHeight;
if (acc > budget) break;
}
},
/** Fill a group's grid with tiles (idempotent).
* @param {HTMLElement} section
*/
_materializeGroup(section) {
const rec = this._groupData.get(section.dataset.group || '');
if (!rec || rec.materialized) return;
rec.materialized = true;
const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid'));
if (!grid) return;
grid.innerHTML = this._renderGroupTiles(rec.files);
grid.style.minHeight = '';
this._setupVideoThumbnails(grid);
this._fadeInTiles(grid);
},
/** Empty a group's grid, freezing its current height as a spacer.
* @param {HTMLElement} section
*/
_dematerializeGroup(section) {
const rec = this._groupData.get(section.dataset.group || '');
if (!rec?.materialized) return;
rec.materialized = false;
const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid'));
if (!grid) return;
grid.style.minHeight = `${grid.offsetHeight}px`;
grid.innerHTML = '';
},
/** Current grid geometry (columns / gap / square tile px) for the active
* mode, used to estimate off-screen group heights.
* @returns {{cols: number, gap: number, tile: number}}
*/
_gridMetrics() {
const width = this._gridWidth();
const mobile = window.matchMedia('(max-width: 768px)').matches;
let min;
let gap;
if (this.groupMode === 'yearly') {
min = mobile ? 80 : 120;
gap = mobile ? 4 : 10;
} else if (this.groupMode === 'monthly') {
min = mobile ? 110 : 180;
gap = mobile ? 2 : 14;
} else {
min = mobile ? 100 : 150;
gap = mobile ? 2 : 12;
}
const cols = Math.max(1, Math.floor((width + gap) / (min + gap)));
const tile = (width - (cols - 1) * gap) / cols;
return { cols, gap, tile };
},
/** Estimated pixel height of a grid holding `count` square tiles.
* @param {number} count
* @returns {number}
*/
_estimateHeight(count) {
if (this.layoutMode === 'justified') {
const width = this._gridWidth();
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
const perRow = Math.max(1, Math.round(width / (target * 1.4)));
const rows = Math.max(1, Math.ceil(count / perRow));
return Math.round(rows * target + (rows - 1) * 8);
}
const { cols, gap, tile } = this._gridMetrics();
const rows = Math.max(1, Math.ceil(count / cols));
return Math.round(rows * tile + (rows - 1) * gap);
},
/** Re-estimate spacer heights for dematerialized groups after a resize. */
_bindResize() {
if (this._resizeHandler) return;
this._resizeHandler = () => {
clearTimeout(this._resizeTimer);
this._resizeTimer = window.setTimeout(() => this._onResize(), 150);
};
window.addEventListener('resize', this._resizeHandler);
},
_onResize() {
if (!this._container?.classList.contains('active')) return;
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (!rec || rec.materialized) continue;
const grid = /** @type {HTMLElement|null} */ (rec.section.querySelector('.photos-grid'));
if (grid) grid.style.minHeight = `${this._estimateHeight(rec.files.length)}px`;
}
},
_unbindResize() {
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
clearTimeout(this._resizeTimer);
},
/**
* Generate HTML for a single photo/video tile
* @param {FileItem} file
* @param {string} [sizeStyle] Inline `width:..;height:..` for justified rows.
*/
_renderTile(file) {
_renderTile(file, sizeStyle) {
const isVideo = file.mime_type?.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : '';
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : '';
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}" tabindex="0" role="button" aria-label="${this._escAttr(file.name)}"${styleAttr}>`;
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
const srcset = cachedThumb
? ''
@@ -261,12 +477,85 @@ const photosView = {
return h;
},
/**
* Inner HTML for a group's grid in the current layout mode.
* @param {FileItem[]} files
* @returns {string}
*/
_renderGroupTiles(files) {
if (this.layoutMode !== 'justified') {
let html = '';
for (const file of files) html += this._renderTile(file);
return html;
}
const rows = this._justifiedRows(files, this._gridWidth());
let html = '';
for (const row of rows) {
html += `<div class="photos-jrow" style="height:${row.height}px">`;
for (const t of row.tiles) {
html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`);
}
html += '</div>';
}
return html;
},
/**
* Pack files into justified rows (Flickr-style): each full row is scaled so
* it fills the container width while preserving every tile's aspect ratio.
* Missing dimensions fall back to a 1:1 aspect.
* @param {FileItem[]} files
* @param {number} width Available content width in px.
* @returns {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>}
*/
_justifiedRows(files, width) {
const gap = 8;
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
/** @type {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */
const rows = [];
/** @type {Array<{file: FileItem, aspect: number}>} */
let cur = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * h)), h: Math.round(h) }))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * target)), h: target }))
});
}
return rows;
},
/** Current grid content width in px (for layout / height estimates). */
_gridWidth() {
const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid'));
return sample?.clientWidth || (this._container?.clientWidth || 1200) - 16;
},
/**
* Fade tiles in as their thumbnails finish loading (kills the pop-in).
* Idempotent — only wires images not already marked loaded.
* @param {ParentNode} [scope] Limit to a subtree (a group grid); defaults to the whole container.
*/
_fadeInTiles() {
this._container?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
_fadeInTiles(scope) {
const root = scope || this._container;
root?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
const img = /** @type {HTMLImageElement} */ (el);
if (img.complete) {
img.classList.add('is-loaded');
@@ -278,40 +567,25 @@ const photosView = {
});
},
/** (Re-)observe the sentinel element for infinite scroll */
_observeSentinel() {
this._destroyObserver();
const sentinel = this._container?.querySelector('.photos-sentinel');
if (sentinel && !this.exhausted) {
this._observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) this._loadPage();
},
{ rootMargin: '400px' }
);
this._observer.observe(sentinel);
}
},
// ── Client-side video thumbnail generation ──────────────────────
// Uses the browser's native video decoder (<video> + <canvas>) to
// extract a frame, show it immediately, and upload to the server
// for permanent caching. Zero server-side dependencies (no ffmpeg).
/** Attach error handlers to video tile images; on failure, extract a
* frame from the video using the browser's built-in codec. */
/** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) {
const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
/** Attach error handlers to video tile images within a freshly
* materialized grid; on failure, extract a frame from the video using
* the browser's built-in codec.
* @param {ParentNode} [scope] Subtree to scan; defaults to the whole container.
*/
_setupVideoThumbnails(scope) {
const root = scope || this._container;
const tiles = /** @type {NodeListOf<HTMLDivElement>|undefined} */ (root?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
if (!tiles) return;
for (const tile of tiles) {
const fileId = tile.dataset.id;
if (!fileId) continue;
if (newIds && !newIds.has(fileId)) continue;
if (this._videoThumbCache.has(fileId)) continue;
const img = tile.querySelector('img');
@@ -356,7 +630,16 @@ const photosView = {
['monthly', i18n.t('photos.view_monthly')],
['yearly', i18n.t('photos.view_yearly')]
];
let html = '<div class="photos-toolbar"><div class="view-toggle">';
let html = '<div class="photos-toolbar">';
// Layout toggle (square crop ↔ justified rows)
html += '<div class="view-toggle photos-layout-toggle">';
html += `<button class="toggle-btn${this.layoutMode === 'square' ? ' active' : ''}" data-layout-mode="square" title="${this._escAttr(i18n.t('photos.layout_square'))}" aria-label="${this._escAttr(i18n.t('photos.layout_square'))}"><i class="fas fa-table-cells"></i></button>`;
html += `<button class="toggle-btn${this.layoutMode === 'justified' ? ' active' : ''}" data-layout-mode="justified" title="${this._escAttr(i18n.t('photos.layout_justified'))}" aria-label="${this._escAttr(i18n.t('photos.layout_justified'))}"><i class="fas fa-grip"></i></button>`;
html += '</div>';
// Grouping toggle (day / month / year)
html += '<div class="view-toggle">';
for (const [mode, label] of modes) {
const active = this.groupMode === mode ? ' active' : '';
html += `<button class="toggle-btn${active}" data-group-mode="${mode}">${this._escHtml(label)}</button>`;
@@ -420,15 +703,28 @@ const photosView = {
return;
}
const layoutBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-layout-mode]'));
if (layoutBtn) {
this.setLayoutMode(/** @type {'square'|'justified'} */ (layoutBtn.dataset.layoutMode));
return;
}
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return;
const id = tile.dataset.id;
const check = target.closest('.photo-check');
// Shift-click extends the selection from the last anchor.
if (id && e.shiftKey && this._selectAnchorId) {
this._selectRange(this._selectAnchorId, id);
return;
}
// If clicking checkbox or in selection mode, toggle select
if (check || this.selected.size > 0) {
this._toggleSelect(id, tile);
this._selectAnchorId = id || null;
return;
}
@@ -439,6 +735,49 @@ const photosView = {
}
},
/**
* Select every item between the anchor and the target (inclusive), in
* timeline order. Tracked in the Set so it survives dematerialized
* groups; currently-visible tiles get the class applied immediately.
* @param {string} anchorId
* @param {string} toId
*/
_selectRange(anchorId, toId) {
const a = this.items.findIndex((f) => f.id === anchorId);
const b = this.items.findIndex((f) => f.id === toId);
if (a < 0 || b < 0) return;
const lo = Math.min(a, b);
const hi = Math.max(a, b);
for (let i = lo; i <= hi; i++) this.selected.add(this.items[i].id);
this._container?.querySelectorAll('.photo-tile').forEach((el) => {
const t = /** @type {HTMLElement} */ (el);
if (t.dataset.id && this.selected.has(t.dataset.id)) t.classList.add('selected');
});
this._selectAnchorId = toId;
this._updateSelectionBar();
},
/**
* Keyboard activation for focused tiles: Enter opens the lightbox (or
* toggles selection when in selection mode); Space toggles selection.
* @param {KeyboardEvent} e
*/
_handleKeydown(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const target = /** @type {Element} */ (e.target);
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return;
e.preventDefault();
const id = tile.dataset.id;
if (e.key === ' ' || this.selected.size > 0) {
this._toggleSelect(id, tile);
this._selectAnchorId = id || null;
return;
}
const idx = this.items.findIndex((f) => f.id === id);
if (idx >= 0) photosLightbox.open(this.items, idx);
},
/**
* Toggle selection of an item
* @param {string} id
@@ -493,7 +832,13 @@ const photosView = {
const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete'));
if (bar_delete) {
bar_delete.onclick = async () => {
if (!confirm('Delete selected items?')) return;
const ok = await Modal.confirmDialog({
title: i18n.t('photos.delete_title'),
message: i18n.t('photos.delete_selected_confirm'),
confirmText: i18n.t('actions.delete'),
icon: 'fa-trash'
});
if (!ok) return;
// One batch request per chunk instead of one DELETE per photo.
// The photos view is files-only, so every id is a file id.
@@ -526,7 +871,6 @@ const photosView = {
if (trashed.size > 0) {
this.items = this.items.filter((f) => !trashed.has(f.id));
for (const id of trashed) this.selected.delete(id);
this._renderedCount = 0;
this._renderFull();
}
// Refresh (or hide) the bar to reflect any items left selected.
@@ -571,9 +915,13 @@ const photosView = {
},
_destroyObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
if (this._materializeObserver) {
this._materializeObserver.disconnect();
this._materializeObserver = null;
}
if (this._sentinelObserver) {
this._sentinelObserver.disconnect();
this._sentinelObserver = null;
}
},
+206 -15
View File
@@ -10,7 +10,9 @@
* original streams in only on demand via the toolbar expand button.
*/
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
@@ -35,6 +37,23 @@ export const photosLightbox = {
*/
_showGeneration: 0,
/** @type {number} Current zoom factor (1 = fit) */
_zoom: 1,
/** @type {number} */
_panX: 0,
/** @type {number} */
_panY: 0,
/** @type {Map<number, {x: number, y: number}>} Active pointers (for pinch) */
_pointers: new Map(),
/** @type {number} */
_pinchStartDist: 0,
/** @type {number} */
_pinchStartZoom: 1,
/** @type {{x: number, y: number, panX: number, panY: number}|null} */
_dragStart: null,
/** @type {{x: number, y: number, t: number}|null} */
_swipeStart: null,
/**
* Register the photosView reference (called from photos.js to avoid circular imports).
* @param {any} pv
@@ -91,6 +110,7 @@ export const photosLightbox = {
}
}, 200);
}
this._resetZoom();
this._unbindKeys();
},
@@ -127,11 +147,13 @@ export const photosLightbox = {
<button class="lightbox-nav lightbox-next"><i class="fas fa-chevron-right"></i></button>
<div class="lightbox-toolbar">
<button class="lb-fullres hidden" title="Full resolution"><i class="fas fa-expand"></i></button>
<button class="lb-info" title="Info"><i class="fas fa-circle-info"></i></button>
<button class="lb-download" title="Download"><i class="fas fa-download"></i></button>
<button class="lb-favorite" title="Favorite"><i class="far fa-star"></i></button>
<button class="lb-delete" title="Delete"><i class="fas fa-trash"></i></button>
</div>
<div class="lightbox-counter"></div>
<div class="lightbox-infopanel hidden"></div>
`;
document.body.appendChild(el);
this._overlay = el;
@@ -149,6 +171,7 @@ export const photosLightbox = {
});
// Toolbar actions (`.lb-fullres` is wired per-item in `_show`)
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-info')).onclick = () => this._toggleInfoPanel();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
@@ -172,6 +195,7 @@ export const photosLightbox = {
_show() {
if (!this._overlay || this.index < 0) return;
const generation = ++this._showGeneration;
this._resetZoom();
const item = this.items[this.index];
const content = this._overlay.querySelector('.lightbox-content');
@@ -182,6 +206,15 @@ export const photosLightbox = {
filename.textContent = item.name;
counter.textContent = `${this.index + 1} / ${this.items.length}`;
// Reflect the current favorite state on the toolbar star.
const favBtn = this._overlay.querySelector('.lb-favorite');
if (favBtn) {
const isFav = favorites.isFavorite(item.id, 'file');
favBtn.classList.toggle('active', isFav);
const favIcon = favBtn.querySelector('i');
if (favIcon) favIcon.className = isFav ? 'fas fa-star' : 'far fa-star';
}
// Format date
const ts = (item.sort_date || item.created_at) * 1000;
const dateStr = new Date(ts).toLocaleDateString(undefined, {
@@ -235,6 +268,7 @@ export const photosLightbox = {
let showingOriginal = isGif;
const img = document.createElement('img');
img.alt = item.name;
this._wireZoomPan(img);
img.addEventListener('load', () => {
if (generation !== this._showGeneration) return;
@@ -297,8 +331,7 @@ export const photosLightbox = {
parts.push(`${metadata.width}×${metadata.height}`);
}
metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
this._fillInfoPanel(metadata, dateStr, sizeStr);
}
} catch (_err) {
// Non-critical, keep existing meta
@@ -317,23 +350,25 @@ export const photosLightbox = {
a.remove();
},
/** Toggle favorite on current item */
/** Toggle favorite on current item (via the favorites module so its
* cache stays in sync — the lightbox can then show the right initial
* star next time the item is opened). */
async _toggleFavorite() {
const item = this.items[this.index];
if (!item || !favorites) return;
if (!item) return;
const isFav = favorites.isFavorite(item.id, 'file');
try {
await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST',
credentials: 'include',
headers: this._headers()
});
const btn = this._overlay.querySelector('.lb-favorite');
if (isFav) {
await favorites.removeFromFavorites(item.id, 'file', item.name);
} else {
await favorites.addToFavorites(item.id, item.name, 'file', null);
}
const btn = this._overlay?.querySelector('.lb-favorite');
if (btn) {
btn.classList.toggle('active');
const nowFav = !isFav;
btn.classList.toggle('active', nowFav);
const icon = btn.querySelector('i');
if (icon) {
icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star';
}
if (icon) icon.className = nowFav ? 'fas fa-star' : 'far fa-star';
}
} catch (err) {
console.error('Favorite toggle failed:', err);
@@ -344,7 +379,13 @@ export const photosLightbox = {
async _delete() {
const item = this.items[this.index];
if (!item) return;
if (!confirm(`Delete ${item.name}?`)) return;
const ok = await Modal.confirmDialog({
title: i18n.t('photos.delete_title'),
message: i18n.t('photos.delete_one_confirm', { name: item.name }),
confirmText: i18n.t('actions.delete'),
icon: 'fa-trash'
});
if (!ok) return;
try {
await fetch(`/api/files/${item.id}`, {
@@ -370,6 +411,156 @@ export const photosLightbox = {
}
},
// ── Zoom / pan / swipe ──────────────────────────────────────────
/** @returns {HTMLImageElement|null} The image element currently shown. */
_currentImg() {
return /** @type {HTMLImageElement|null} */ (this._overlay?.querySelector('.lightbox-content img') || null);
},
/** Reset zoom/pan state (per item and on close). */
_resetZoom() {
this._zoom = 1;
this._panX = 0;
this._panY = 0;
this._pointers.clear();
this._pinchStartDist = 0;
this._dragStart = null;
this._swipeStart = null;
},
_applyTransform() {
const img = this._currentImg();
if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`;
},
/**
* Set the zoom factor (clamped 1–5), centered. Resets pan at 1.
* @param {number} z
*/
_setZoom(z) {
z = Math.min(Math.max(z, 1), 5);
if (z === 1) {
this._panX = 0;
this._panY = 0;
}
this._zoom = z;
this._applyTransform();
const img = this._currentImg();
if (img) img.classList.toggle('is-zoomed', z > 1);
},
/**
* Wire wheel-zoom, double-click zoom, drag-pan, pinch-zoom and (when not
* zoomed) touch swipe-to-navigate onto a photo element.
* @param {HTMLImageElement} img
*/
_wireZoomPan(img) {
img.style.transformOrigin = 'center center';
img.style.touchAction = 'none';
img.addEventListener(
'wheel',
(e) => {
e.preventDefault();
this._setZoom(this._zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2));
},
{ passive: false }
);
img.addEventListener('dblclick', (e) => {
e.preventDefault();
this._setZoom(this._zoom > 1 ? 1 : 2.5);
});
img.addEventListener('pointerdown', (e) => {
img.setPointerCapture?.(e.pointerId);
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (this._pointers.size === 2) {
const pts = [...this._pointers.values()];
this._pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
this._pinchStartZoom = this._zoom;
} else {
this._dragStart = { x: e.clientX, y: e.clientY, panX: this._panX, panY: this._panY };
this._swipeStart = { x: e.clientX, y: e.clientY, t: Date.now() };
}
});
img.addEventListener('pointermove', (e) => {
if (!this._pointers.has(e.pointerId)) return;
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (this._pointers.size === 2 && this._pinchStartDist > 0) {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
this._setZoom(this._pinchStartZoom * (dist / this._pinchStartDist));
} else if (this._zoom > 1 && this._dragStart) {
this._panX = this._dragStart.panX + (e.clientX - this._dragStart.x);
this._panY = this._dragStart.panY + (e.clientY - this._dragStart.y);
this._applyTransform();
}
});
const endPointer = (/** @type {PointerEvent} */ e) => {
const wasPinch = this._pointers.size === 2;
this._pointers.delete(e.pointerId);
if (!wasPinch && this._zoom === 1 && this._swipeStart && e.pointerType === 'touch') {
const dx = e.clientX - this._swipeStart.x;
const dy = e.clientY - this._swipeStart.y;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx > 0) this.prev();
else this.next();
}
}
if (wasPinch) this._pinchStartDist = 0;
this._dragStart = null;
this._swipeStart = null;
};
img.addEventListener('pointerup', endPointer);
img.addEventListener('pointercancel', endPointer);
},
// ── Info panel ──────────────────────────────────────────────────
/** Toggle the EXIF info panel. */
_toggleInfoPanel() {
this._overlay?.querySelector('.lightbox-infopanel')?.classList.toggle('hidden');
},
/**
* Populate the info panel from fetched EXIF metadata.
* @param {FileMetadata} metadata
* @param {string} dateStr
* @param {string} sizeStr
*/
_fillInfoPanel(metadata, dateStr, sizeStr) {
const panel = this._overlay?.querySelector('.lightbox-infopanel');
if (!panel) return;
const item = this.items[this.index];
const rows = [this._infoRow('fa-image', item?.name || ''), this._infoRow('fa-calendar', dateStr)];
if (sizeStr) rows.push(this._infoRow('fa-hard-drive', sizeStr));
if (metadata.width && metadata.height) {
rows.push(this._infoRow('fa-ruler-combined', `${metadata.width} × ${metadata.height}`));
}
if (metadata.camera_make || metadata.camera_model) {
rows.push(this._infoRow('fa-camera', [metadata.camera_make, metadata.camera_model].filter(Boolean).join(' ')));
}
if (metadata.latitude != null && metadata.longitude != null) {
rows.push(this._infoRow('fa-location-dot', `${metadata.latitude.toFixed(5)}, ${metadata.longitude.toFixed(5)}`));
}
panel.innerHTML = rows.join('');
},
/**
* @param {string} icon FontAwesome class
* @param {string} text
* @returns {string}
*/
_infoRow(icon, text) {
const d = document.createElement('div');
d.textContent = text;
return `<div class="lb-info-row"><i class="fas ${icon}"></i><span>${d.innerHTML}</span></div>`;
},
/** Keyboard navigation */
_bindKeys() {
this._keyHandler = (e) => {
+394
View File
@@ -0,0 +1,394 @@
/**
* OxiCloud - Places (photo map)
*
* Renders the user's geotagged photos on a self-hosted MapLibre GL map.
* Photos are clustered *server-side* (GET /api/photos/geo, grid aggregation),
* so we draw one lightweight HTML marker per cluster — no glyph/sprite assets
* and no client-side clustering needed. The vector basemap is optional: if a
* `static/basemaps/basemap.pmtiles` is present it is read directly by the
* browser over HTTP Range (pmtiles.js); otherwise the map falls back to a
* plain themed background and still shows the photo clusters.
*
* MapLibre + pmtiles.js are heavy, so they are vendored and lazy-loaded only
* when the Places tab is first opened.
*/
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { peopleView } from './people.js';
import { photosView } from './photos.js';
import { photosLightbox } from './photosLightbox.js';
/** @import {FileItem} from '../../core/types.js' */
/** @typedef {{lng: number, lat: number, count: number, sample_file_id: string}} GeoClusterItem */
const BASEMAP_URL = '/basemaps/basemap.pmtiles';
export const placesView = {
/** @type {HTMLElement|null} */
_container: null,
/** @type {HTMLElement|null} */
_subnav: null,
/** @type {any} MapLibre Map instance */
_map: null,
/** @type {any[]} current cluster markers */
_markers: [],
/** @type {{maplibregl: any, pmtiles: any}|null} */
_libs: null,
/** @type {number} debounce timer for moveend refresh */
_moveTimer: 0,
/** @type {'moments'|'places'|'people'} */
_activeTab: 'moments',
/** @type {boolean|null} cached basemap availability */
_hasBasemap: null,
/** Auth headers (HttpOnly cookies + CSRF) */
_headers() {
return getCsrfHeaders();
},
// ── Sub-navigation (Moments | Places) ───────────────────────────
// Lives at the top of `.content-area`; mounted while the Photos section
// is active and torn down (hidden) when the user leaves it.
/** Create/show the Moments|Places tab bar and the map container. */
mountTabs() {
const contentArea = document.querySelector('.content-area');
if (!contentArea) return;
if (!this._subnav) {
const bar = document.createElement('div');
bar.className = 'photos-subnav';
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" 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) => {
const btn = /** @type {HTMLElement} */ (e.target).closest('[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');
if (!this._container) {
const el = document.createElement('div');
el.id = 'places-container';
el.className = 'places-container';
contentArea.appendChild(el);
this._container = el;
}
// Always (re)enter the Photos section on the Moments tab.
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). */
hide() {
this._container?.classList.remove('active');
},
/**
* @param {'moments'|'places'|'people'} tab
*/
_switchTab(tab) {
if (tab === this._activeTab) return;
this._activeTab = tab;
this._setActiveTab(tab);
// 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 */
_setActiveTab(tab) {
this._subnav?.querySelectorAll('[data-ptab]').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-ptab') === tab);
});
},
// ── Map ─────────────────────────────────────────────────────────
/** Reveal the map container and (lazily) build the map. */
async _showMap() {
if (!this._container) return;
this._container.classList.add('active');
if (this._map) {
this._map.resize();
this._refreshClusters(false);
return;
}
this._container.innerHTML = '<div class="places-map" id="places-map"></div>' + '<div class="places-loading"><i class="fas fa-spinner"></i></div>';
try {
const libs = await this._loadLibs();
await this._initMap(libs);
} catch (err) {
console.error('Places map failed to load:', err);
if (this._container) {
this._container.innerHTML = `<div class="places-error">${this._esc(i18n.t('photos.map_error'))}</div>`;
}
}
},
/** Inject a vendored script once, resolving when it has loaded.
* @param {string} src
* @returns {Promise<void>}
*/
_loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[data-vendor="${src}"]`)) {
resolve();
return;
}
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.vendor = src;
s.addEventListener('load', () => resolve());
s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
document.head.appendChild(s);
});
},
/** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */
async _loadLibs() {
if (this._libs) return this._libs;
if (!document.querySelector('link[data-vendor="maplibre-css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = '/js/vendors/maplibre-gl.css';
l.dataset.vendor = 'maplibre-css';
document.head.appendChild(l);
}
await this._loadScript('/js/vendors/maplibre-gl.js');
await this._loadScript('/js/vendors/pmtiles.js');
const w = /** @type {any} */ (window);
this._libs = { maplibregl: w.maplibregl, pmtiles: w.pmtiles };
return this._libs;
},
/** Whether a basemap .pmtiles is available (cached after first probe). */
async _checkBasemap() {
if (this._hasBasemap !== null) return this._hasBasemap;
try {
const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } });
this._hasBasemap = res.ok; // 200/206 = present, 404 = absent
} catch {
this._hasBasemap = false;
}
return this._hasBasemap;
},
/**
* @param {{maplibregl: any, pmtiles: any}} libs
*/
async _initMap({ maplibregl, pmtiles }) {
const hasBasemap = await this._checkBasemap();
if (hasBasemap) {
try {
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
} catch (e) {
console.error('pmtiles protocol registration failed:', e);
}
}
this._map = new maplibregl.Map({
container: 'places-map',
style: hasBasemap ? this._basemapStyle() : this._blankStyle(),
center: [0, 25],
zoom: 1.3,
attributionControl: false
});
this._map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
if (hasBasemap) {
this._map.addControl(
new maplibregl.AttributionControl({
customAttribution: 'Protomaps © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
})
);
}
this._map.on('load', () => {
this._removeLoading();
this._refreshClusters(true);
});
this._map.on('moveend', () => {
clearTimeout(this._moveTimer);
this._moveTimer = window.setTimeout(() => this._refreshClusters(false), 250);
});
},
_removeLoading() {
this._container?.querySelector('.places-loading')?.remove();
},
/** Fetch clusters for the current viewport and render them.
* @param {boolean} fit Fit the map to the returned clusters (first load).
*/
async _refreshClusters(fit) {
if (!this._map) return;
const b = this._map.getBounds();
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
const zoom = Math.round(this._map.getZoom());
try {
const res = await fetch(`/api/photos/geo?bbox=${bbox}&zoom=${zoom}`, {
credentials: 'include',
headers: this._headers()
});
if (!res.ok) return;
/** @type {GeoClusterItem[]} */
const clusters = await res.json();
this._renderMarkers(clusters);
if (fit && clusters.length) this._fitTo(clusters);
} catch (err) {
console.error('Places geo fetch failed:', err);
}
},
/** @param {GeoClusterItem[]} clusters */
_renderMarkers(clusters) {
for (const m of this._markers) m.remove();
this._markers = [];
if (!this._libs) return;
const { maplibregl } = this._libs;
for (const c of clusters) {
const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6));
const el = document.createElement('div');
el.className = 'places-cluster';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.backgroundImage = `url(/api/files/${c.sample_file_id}/thumbnail/icon)`;
if (c.count > 1) {
el.innerHTML = `<span class="places-cluster-count">${c.count}</span>`;
}
el.addEventListener('click', () => this._onClusterClick(c));
const marker = new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(this._map);
this._markers.push(marker);
}
},
/** @param {GeoClusterItem} c */
_onClusterClick(c) {
const zoom = this._map.getZoom();
if (c.count === 1 || zoom >= 16) {
// Drill down to the representative photo. We only know its id, so
// build a minimal item and let the lightbox load the rest.
const item = /** @type {FileItem} */ (
/** @type {any} */ ({
id: c.sample_file_id,
name: '',
mime_type: 'image/jpeg',
created_at: 0,
sort_date: 0,
size_formatted: ''
})
);
photosLightbox.open([item], 0);
} else {
this._map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) });
}
},
/** @param {GeoClusterItem[]} clusters */
_fitTo(clusters) {
if (!this._libs) return;
const { maplibregl } = this._libs;
const bounds = new maplibregl.LngLatBounds();
for (const c of clusters) bounds.extend([c.lng, c.lat]);
if (!bounds.isEmpty()) {
this._map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 });
}
},
/** @returns {boolean} */
_isDark() {
return document.documentElement.getAttribute('data-color-scheme') === 'dark';
},
/** Minimal MapLibre style: themed background only (no basemap). */
_blankStyle() {
return {
version: 8,
sources: {},
layers: [
{
id: 'bg',
type: 'background',
paint: { 'background-color': this._isDark() ? '#0f172a' : '#e8eef3' }
}
]
};
},
/** Label-light Protomaps vector style (no glyphs/sprites required). */
_basemapStyle() {
const dark = this._isDark();
const c = dark
? { earth: '#1b2433', land: '#222d3d', water: '#0d1b2a', roads: '#3a4860', buildings: '#2a3547', boundary: '#475569' }
: { earth: '#f3efe9', land: '#e9e4da', water: '#a8c8e8', roads: '#ffffff', buildings: '#e0dccf', boundary: '#c9c2b6' };
return {
version: 8,
sources: {
protomaps: {
type: 'vector',
url: `pmtiles://${BASEMAP_URL}`,
attribution: 'Protomaps © OpenStreetMap'
}
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': c.earth } },
{ id: 'earth', type: 'fill', source: 'protomaps', 'source-layer': 'earth', paint: { 'fill-color': c.earth } },
{ id: 'landuse', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', paint: { 'fill-color': c.land, 'fill-opacity': 0.6 } },
{ id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': c.water } },
{ id: 'roads', type: 'line', source: 'protomaps', 'source-layer': 'roads', minzoom: 7, paint: { 'line-color': c.roads, 'line-width': 0.8 } },
{ id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', minzoom: 13, paint: { 'fill-color': c.buildings } },
{
id: 'boundaries',
type: 'line',
source: 'protomaps',
'source-layer': 'boundaries',
paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] }
}
]
};
},
/** @param {any} s */
_esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
};
+3 -1
View File
@@ -164,7 +164,9 @@ const grants = {
/**
* Create a new grant.
* Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`.
* Body mirrors `CreateGrantDto`: `{ subject, resource, role, expires_at? }`.
* Strictly role-keyed since the cleanup PR — the per-permission shape
* is no longer accepted.
*
* Response shape (PR N1 — `CreateGrantResponseDto`):
*
+2
View File
@@ -0,0 +1,2 @@
maplibre-gl 5.24.0
pmtiles 4.4.1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// OxiCloud Service Worker
// FIXME: generate cache name according build ?
const CACHE_NAME = 'oxicloud-cache-v27';
const CACHE_NAME = 'oxicloud-cache-v28';
// Only cache static assets — NOT HTML files.
// HTML files are served network-first so browsers always get the latest