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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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, '<');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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`):
|
||||
*
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
maplibre-gl 5.24.0
|
||||
pmtiles 4.4.1
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+59
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user