Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m56s Details

This commit is contained in:
Jeff Emmett 2026-04-12 16:36:39 -04:00
commit b90f095f47
4 changed files with 717 additions and 127 deletions

View File

@ -1,8 +1,8 @@
/**
* <folk-photo-gallery> Community photo gallery powered by Immich.
*
* Browse shared albums, view recent photos, and open the full
* Immich interface for uploads and management.
* Role-aware: upload (member+), delete (moderator+), Immich embed (admin).
* Shows setup prompt for admins when rPhotos isn't enabled for the space.
*
* Attributes:
* space space slug (default: "demo")
@ -15,6 +15,16 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import type { DocumentId } from "../../../shared/local-first/document";
import { photosSchema, photosDocId } from "../schemas";
type UserRole = 'viewer' | 'member' | 'moderator' | 'admin' | null;
interface SpaceAlbumInfo {
id: string;
name: string;
immichAlbumId: string;
createdBy: string;
createdAt: number;
}
interface Album {
id: string;
albumName: string;
@ -39,18 +49,27 @@ interface Asset {
fileCreatedAt: string;
}
const ROLE_LEVELS: Record<string, number> = { viewer: 0, member: 1, moderator: 2, admin: 3 };
function roleAtLeast(actual: string | null, required: string): boolean {
if (!actual) return false;
return (ROLE_LEVELS[actual] ?? 0) >= (ROLE_LEVELS[required] ?? 0);
}
class FolkPhotoGallery extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private view: "gallery" | "album" | "lightbox" = "gallery";
private albums: Album[] = [];
private albums: (Album | SpaceAlbumInfo)[] = [];
private assets: Asset[] = [];
private albumAssets: Asset[] = [];
private selectedAlbum: Album | null = null;
private selectedAlbum: (Album | SpaceAlbumInfo) | null = null;
private lightboxAsset: Asset | null = null;
private loading = false;
private error = "";
private showingSampleData = false;
private userRole: UserRole = null;
private spaceEnabled = false;
private uploading = false;
private _tour!: TourEngine;
private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null;
@ -59,7 +78,7 @@ class FolkPhotoGallery extends HTMLElement {
private static readonly TOUR_STEPS = [
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
{ target: '.photo-cell', title: "Photo Grid", message: "Click any photo to open it in the lightbox viewer.", advanceOnClick: false },
{ target: '.rapp-nav__btn', title: "Open Immich", message: "Launch the full Immich interface for uploads and management.", advanceOnClick: false },
{ target: '.rapp-nav__btn', title: "Actions", message: "Upload photos, open albums, or manage your gallery.", advanceOnClick: false },
];
constructor() {
@ -78,13 +97,13 @@ class FolkPhotoGallery extends HTMLElement {
if (this.space === "demo") {
this.loadDemoData();
} else {
this.loadGallery();
this.loadSetupAndGallery();
this.subscribeOffline();
}
if (!localStorage.getItem("rphotos_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rphotos', context: this.selectedAlbum?.albumName || 'Photos' }));
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rphotos', context: this.selectedAlbum ? ('name' in this.selectedAlbum ? this.selectedAlbum.name : (this.selectedAlbum as Album).albumName) : 'Photos' }));
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
}
@ -118,7 +137,54 @@ class FolkPhotoGallery extends HTMLElement {
} catch { /* runtime unavailable */ }
}
private getToken(): string | null {
return localStorage.getItem('encryptid_token');
}
private authHeaders(): Record<string, string> {
const token = this.getToken();
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
private async authFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const h = { ...this.authHeaders(), ...(opts.headers as Record<string, string> || {}) };
return fetch(url, { ...opts, headers: h });
}
private async resolveUserRole() {
const token = this.getToken();
if (!token) { this.userRole = 'viewer'; return; }
try {
const base = this.getApiBase();
// Fetch setup status and role in parallel
const [setupRes, accessRes] = await Promise.all([
this.authFetch(`${base}/api/setup/status`),
this.authFetch(`/api/space-access/${encodeURIComponent(this.space)}`),
]);
if (setupRes.ok) {
const data = await setupRes.json();
this.spaceEnabled = data.enabled;
}
if (accessRes.ok) {
const data = await accessRes.json();
this.userRole = data.role || 'viewer';
} else {
this.userRole = 'viewer';
}
} catch {
this.userRole = 'viewer';
}
}
private async loadSetupAndGallery() {
this.loading = true;
this.render();
await this.resolveUserRole();
await this.loadGallery();
}
private loadDemoData() {
this.userRole = 'viewer';
this.albums = [
{ id: "demo-album-1", albumName: "Community Gathering", description: "Photos from our community events", assetCount: 12, albumThumbnailAssetId: null, updatedAt: "2026-02-15T10:00:00Z", shared: true },
{ id: "demo-album-2", albumName: "Workshop Series", description: "Hands-on learning sessions", assetCount: 8, albumThumbnailAssetId: null, updatedAt: "2026-02-10T14:30:00Z", shared: true },
@ -181,6 +247,10 @@ class FolkPhotoGallery extends HTMLElement {
return `${this.getApiBase()}/album`;
}
private getAlbumName(album: Album | SpaceAlbumInfo): string {
return 'albumName' in album ? album.albumName : album.name;
}
private async loadGallery() {
this.loading = true;
this.render();
@ -188,8 +258,8 @@ class FolkPhotoGallery extends HTMLElement {
const base = this.getApiBase();
try {
const [albumsRes, assetsRes] = await Promise.all([
fetch(`${base}/api/albums`),
fetch(`${base}/api/assets?size=24`),
this.authFetch(`${base}/api/albums`),
this.authFetch(`${base}/api/assets?size=24`),
]);
const albumsData = await albumsRes.json();
const assetsData = await assetsRes.json();
@ -199,7 +269,7 @@ class FolkPhotoGallery extends HTMLElement {
// Fall through to empty check below
}
if (this.albums.length === 0 && this.assets.length === 0) {
if (this.albums.length === 0 && this.assets.length === 0 && !this.spaceEnabled) {
this.showingSampleData = true;
this.loading = false;
this.loadDemoData();
@ -210,7 +280,7 @@ class FolkPhotoGallery extends HTMLElement {
this.render();
}
private async loadAlbum(album: Album) {
private async loadAlbum(album: Album | SpaceAlbumInfo) {
this.selectedAlbum = album;
this.view = "album";
@ -225,7 +295,7 @@ class FolkPhotoGallery extends HTMLElement {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/albums/${album.id}`);
const res = await this.authFetch(`${base}/api/albums/${album.id}`);
const data = await res.json();
this.albumAssets = data.assets || [];
} catch {
@ -278,7 +348,100 @@ class FolkPhotoGallery extends HTMLElement {
});
}
// ── Upload ──
private async handleUpload() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.onchange = async () => {
const files = input.files;
if (!files || files.length === 0) return;
this.uploading = true;
this.error = "";
this.render();
const base = this.getApiBase();
let successCount = 0;
for (const file of Array.from(files)) {
try {
const formData = new FormData();
formData.append('file', file);
const res = await this.authFetch(`${base}/api/upload`, {
method: 'POST',
body: formData,
});
if (res.ok) successCount++;
else {
const data = await res.json().catch(() => ({}));
this.error = data.error || `Upload failed (${res.status})`;
}
} catch (err: any) {
this.error = `Upload failed: ${err.message}`;
}
}
this.uploading = false;
if (successCount > 0) {
await this.loadGallery();
} else {
this.render();
}
};
input.click();
}
// ── Delete asset ──
private async handleDeleteAsset(assetId: string) {
if (!confirm('Delete this photo? This cannot be undone.')) return;
const base = this.getApiBase();
try {
const res = await this.authFetch(`${base}/api/assets/${assetId}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
this.error = data.error || 'Delete failed';
this.render();
return;
}
// Remove from local state and go back
this.assets = this.assets.filter(a => a.id !== assetId);
this.albumAssets = this.albumAssets.filter(a => a.id !== assetId);
this.goBack();
} catch (err: any) {
this.error = `Delete failed: ${err.message}`;
this.render();
}
}
// ── Setup ──
private async handleEnableRPhotos() {
const base = this.getApiBase();
try {
const res = await this.authFetch(`${base}/api/setup`, { method: 'POST' });
if (res.ok) {
this.spaceEnabled = true;
await this.loadGallery();
} else {
const data = await res.json().catch(() => ({}));
this.error = data.error || 'Setup failed';
this.render();
}
} catch (err: any) {
this.error = `Setup failed: ${err.message}`;
this.render();
}
}
private render() {
const canUpload = roleAtLeast(this.userRole, 'member') && this.spaceEnabled;
const canDelete = roleAtLeast(this.userRole, 'moderator');
const isAdmin = roleAtLeast(this.userRole, 'admin');
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
@ -293,6 +456,9 @@ class FolkPhotoGallery extends HTMLElement {
.rapp-nav__btn:hover { background: #6366f1; }
.rapp-nav__btn--secondary { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #94a3b8; }
.rapp-nav__btn--secondary:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.3); }
.rapp-nav__btn--danger { background: #dc2626; }
.rapp-nav__btn--danger:hover { background: #ef4444; }
.rapp-nav__btn:disabled { opacity: 0.5; cursor: not-allowed; }
.albums-section { margin-bottom: 2rem; }
.section-title { font-size: 13px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; }
@ -379,6 +545,24 @@ class FolkPhotoGallery extends HTMLElement {
justify-content: center;
}
.lightbox-close:hover { background: rgba(255,255,255,0.2); }
.lightbox-actions {
position: absolute;
top: 16px;
left: 16px;
display: flex;
gap: 8px;
}
.lightbox-delete {
background: rgba(220,38,38,0.8);
border: none;
color: #fff;
font-size: 14px;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
.lightbox-delete:hover { background: rgba(239,68,68,0.9); }
.lightbox-info {
text-align: center;
color: #94a3b8;
@ -390,6 +574,17 @@ class FolkPhotoGallery extends HTMLElement {
.empty h3 { color: #94a3b8; margin: 0 0 0.5rem; }
.empty p { margin: 0 0 1.5rem; font-size: 0.9rem; }
.setup-card {
text-align: center;
padding: 3rem 1.5rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
margin: 1rem 0;
}
.setup-card h3 { color: #e2e8f0; margin: 0 0 0.5rem; }
.setup-card p { color: #64748b; margin: 0 0 1.5rem; font-size: 0.9rem; }
.loading { text-align: center; color: #64748b; padding: 3rem; }
.error { text-align: center; color: #f87171; padding: 1.5rem; background: rgba(248,113,113,0.08); border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
@ -419,7 +614,8 @@ class FolkPhotoGallery extends HTMLElement {
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading">Loading photos...</div>' : ""}
${!this.loading ? this.renderView() : ""}
${this.uploading ? '<div class="loading">Uploading...</div>' : ""}
${!this.loading && !this.uploading ? this.renderView() : ""}
${this.view === "lightbox" && this.lightboxAsset ? this.renderLightbox() : ""}
`;
@ -446,21 +642,46 @@ class FolkPhotoGallery extends HTMLElement {
}
private renderView(): string {
// Not enabled — show setup or placeholder
if (!this.isDemo() && !this.spaceEnabled) {
return this.renderSetupPrompt();
}
if (this.view === "album" && this.selectedAlbum) return this.renderAlbum();
return this.renderGalleryView();
}
private renderSetupPrompt(): string {
if (roleAtLeast(this.userRole, 'admin')) {
return `
<div class="setup-card">
<div style="font-size:3rem;margin-bottom:1rem;opacity:0.4">📸</div>
<h3>Enable rPhotos for this space</h3>
<p>Create a dedicated photo library for this space, backed by Immich. Members will be able to upload and browse photos.</p>
<button class="rapp-nav__btn" id="btn-enable">Enable rPhotos</button>
</div>
`;
}
return `
<div class="empty">
<div class="empty-icon">📸</div>
<h3>rPhotos isn't enabled yet</h3>
<p>Ask a space admin to enable the photo library for this space.</p>
</div>
`;
}
private renderGalleryView(): string {
const hasContent = this.albums.length > 0 || this.assets.length > 0;
const canUpload = roleAtLeast(this.userRole, 'member') && this.spaceEnabled && !this.isDemo();
const isAdmin = roleAtLeast(this.userRole, 'admin');
return `
<div class="rapp-nav">
<span class="rapp-nav__title">Photos</span>
<div class="rapp-nav__actions">
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
Open Immich
</a>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
${canUpload ? '<button class="rapp-nav__btn" id="btn-upload">Upload</button>' : ''}
${isAdmin ? `<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">Open Immich</a>` : ''}
<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
</div>
@ -470,32 +691,33 @@ class FolkPhotoGallery extends HTMLElement {
<div class="empty">
<div class="empty-icon">📸</div>
<h3>No photos yet</h3>
<p>Upload photos through Immich to see them here. Shared albums will appear automatically.</p>
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
Open Immich to Upload
</a>
<p>${canUpload ? 'Upload the first photo to get started!' : 'Photos uploaded by members will appear here.'}</p>
${canUpload ? '<button class="rapp-nav__btn" id="btn-upload-empty">Upload Photos</button>' : ''}
</div>
` : ""}
${this.albums.length > 0 ? `
<div class="albums-section">
<div class="section-title">Shared Albums</div>
<div class="section-title">Albums</div>
<div class="albums-grid">
${this.albums.map((a) => `
${this.albums.map((a) => {
const name = this.getAlbumName(a);
const isImmichAlbum = 'albumName' in a;
return `
<div class="album-card" data-album-id="${a.id}" data-collab-id="album:${a.id}">
<div class="album-thumb">
${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
: a.albumThumbnailAssetId
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(name)}</div>`
: isImmichAlbum && (a as Album).albumThumbnailAssetId
? `<img src="${this.thumbUrl((a as Album).albumThumbnailAssetId!)}" alt="${this.esc(name)}" loading="lazy">`
: '<span class="album-thumb-empty">📷</span>'}
</div>
<div class="album-info">
<div class="album-name">${this.esc(a.albumName)}</div>
<div class="album-meta">${a.assetCount} photo${a.assetCount !== 1 ? "s" : ""}</div>
<div class="album-name">${this.esc(name)}</div>
${isImmichAlbum ? `<div class="album-meta">${(a as Album).assetCount} photo${(a as Album).assetCount !== 1 ? "s" : ""}</div>` : ''}
</div>
</div>
`).join("")}
`;}).join("")}
</div>
</div>
` : ""}
@ -517,14 +739,14 @@ class FolkPhotoGallery extends HTMLElement {
private renderAlbum(): string {
const album = this.selectedAlbum!;
const name = this.getAlbumName(album);
const isAdmin = roleAtLeast(this.userRole, 'admin');
return `
<div class="rapp-nav">
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="gallery">\u2190 Photos</button>' : ''}
<span class="rapp-nav__title">${this.esc(album.albumName)}</span>
<span class="rapp-nav__title">${this.esc(name)}</span>
<div class="rapp-nav__actions">
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
Open in Immich
</a>
${isAdmin ? `<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">Open in Immich</a>` : ''}
</div>
</div>
@ -532,7 +754,7 @@ class FolkPhotoGallery extends HTMLElement {
<div class="empty">
<div class="empty-icon">📷</div>
<h3>Album is empty</h3>
<p>Add photos to this album in Immich.</p>
<p>Upload photos to populate this album.</p>
</div>
` : `
<div class="photo-grid">
@ -553,6 +775,7 @@ class FolkPhotoGallery extends HTMLElement {
const info = asset.exifInfo;
const location = [info?.city, info?.country].filter(Boolean).join(", ");
const camera = [info?.make, info?.model].filter(Boolean).join(" ");
const canDelete = roleAtLeast(this.userRole, 'moderator') && !this.isDemo();
const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
@ -560,6 +783,11 @@ class FolkPhotoGallery extends HTMLElement {
return `
<div class="lightbox" data-lightbox>
<button class="lightbox-close" data-close-lightbox></button>
${canDelete ? `
<div class="lightbox-actions">
<button class="lightbox-delete" data-delete-asset="${asset.id}">Delete</button>
</div>
` : ''}
${demoMeta
? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>`
: `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">`}
@ -575,6 +803,18 @@ class FolkPhotoGallery extends HTMLElement {
private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelector("#btn-upload")?.addEventListener("click", () => this.handleUpload());
this.shadow.querySelector("#btn-upload-empty")?.addEventListener("click", () => this.handleUpload());
this.shadow.querySelector("#btn-enable")?.addEventListener("click", () => this.handleEnableRPhotos());
// Delete button in lightbox
this.shadow.querySelectorAll("[data-delete-asset]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const assetId = (el as HTMLElement).dataset.deleteAsset!;
this.handleDeleteAsset(assetId);
});
});
// Album cards
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {

View File

@ -0,0 +1,112 @@
/**
* Centralized Immich API client for rPhotos.
*
* All calls use the single admin RPHOTOS_API_KEY internally.
* This replaces scattered raw fetch calls in mod.ts.
*/
const IMMICH_BASE = process.env.RPHOTOS_IMMICH_URL || "http://localhost:2284";
const IMMICH_API_KEY = process.env.RPHOTOS_API_KEY || "";
function headers(extra?: Record<string, string>): Record<string, string> {
return { "x-api-key": IMMICH_API_KEY, ...extra };
}
// ── Albums ──
export async function immichCreateAlbum(name: string, description?: string) {
const res = await fetch(`${IMMICH_BASE}/api/albums`, {
method: "POST",
headers: headers({ "Content-Type": "application/json" }),
body: JSON.stringify({ albumName: name, description: description || "" }),
});
if (!res.ok) throw new Error(`Immich createAlbum failed: ${res.status}`);
return res.json() as Promise<{ id: string; albumName: string }>;
}
export async function immichDeleteAlbum(albumId: string) {
const res = await fetch(`${IMMICH_BASE}/api/albums/${albumId}`, {
method: "DELETE",
headers: headers(),
});
if (!res.ok) throw new Error(`Immich deleteAlbum failed: ${res.status}`);
}
export async function immichGetAlbum(albumId: string) {
const res = await fetch(`${IMMICH_BASE}/api/albums/${albumId}`, {
headers: headers(),
});
if (!res.ok) return null;
return res.json();
}
// ── Search ──
export async function immichSearchInAlbum(albumId: string, opts: { size?: number; type?: string } = {}) {
const res = await fetch(`${IMMICH_BASE}/api/search/metadata`, {
method: "POST",
headers: headers({ "Content-Type": "application/json" }),
body: JSON.stringify({
size: opts.size || 50,
order: "desc",
type: opts.type || "IMAGE",
...(albumId ? { albumIds: [albumId] } : {}),
}),
});
if (!res.ok) return { items: [] };
const data = await res.json();
return { items: data.assets?.items || [] };
}
// ── Assets ──
export async function immichUploadAsset(formData: FormData) {
const res = await fetch(`${IMMICH_BASE}/api/assets`, {
method: "POST",
headers: { "x-api-key": IMMICH_API_KEY },
body: formData,
});
if (!res.ok) throw new Error(`Immich upload failed: ${res.status}`);
return res.json() as Promise<{ id: string; status: string }>;
}
export async function immichAddAssetsToAlbum(albumId: string, assetIds: string[]) {
const res = await fetch(`${IMMICH_BASE}/api/albums/${albumId}/assets`, {
method: "PUT",
headers: headers({ "Content-Type": "application/json" }),
body: JSON.stringify({ ids: assetIds }),
});
if (!res.ok) throw new Error(`Immich addAssets failed: ${res.status}`);
return res.json();
}
export async function immichDeleteAssets(assetIds: string[]) {
const res = await fetch(`${IMMICH_BASE}/api/assets`, {
method: "DELETE",
headers: headers({ "Content-Type": "application/json" }),
body: JSON.stringify({ ids: assetIds, force: true }),
});
if (!res.ok) throw new Error(`Immich deleteAssets failed: ${res.status}`);
}
export async function immichGetAssetThumbnail(id: string, size: string = "thumbnail") {
const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/thumbnail?size=${size}`, {
headers: headers(),
});
if (!res.ok) return null;
return {
body: await res.arrayBuffer(),
contentType: res.headers.get("Content-Type") || "image/jpeg",
};
}
export async function immichGetAssetOriginal(id: string) {
const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/original`, {
headers: headers(),
});
if (!res.ok) return null;
return {
body: await res.arrayBuffer(),
contentType: res.headers.get("Content-Type") || "image/jpeg",
};
}

View File

@ -1,9 +1,8 @@
/**
* Photos module community photo commons powered by Immich.
*
* Provides a gallery UI within the rSpace shell that connects to
* the Immich instance at {space}.rphotos.online. Proxies API requests
* for albums and thumbnails to avoid CORS issues.
* Per-space isolation: each space gets its own Immich album, with
* CRUD gated by the space role system (viewer/member/moderator/admin).
*/
import { Hono } from "hono";
@ -12,19 +11,30 @@ import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { EncryptIDClaims } from "../../server/auth";
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import { photosSchema, photosDocId } from './schemas';
import type { PhotosDoc, SharedAlbum, PhotoAnnotation } from './schemas';
import type { PhotosDoc, SpaceAlbum } from './schemas';
import {
immichCreateAlbum,
immichGetAlbum,
immichSearchInAlbum,
immichUploadAsset,
immichAddAssetsToAlbum,
immichDeleteAssets,
immichGetAssetThumbnail,
immichGetAssetOriginal,
} from './lib/immich-client';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
const IMMICH_BASE = process.env.RPHOTOS_IMMICH_URL || "http://localhost:2284";
const IMMICH_PUBLIC_URL = process.env.RPHOTOS_IMMICH_PUBLIC_URL || "https://demo.rphotos.online";
// ── Local-first helpers ──
@ -42,34 +52,233 @@ function ensurePhotosDoc(space: string): PhotosDoc {
return doc;
}
// ── CRUD: Curated Albums ──
// ── Role helper ──
async function requireRole(
c: any,
minRole: SpaceRoleString,
): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString; space: string } | Response> {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
routes.get("/api/curations", async (c) => {
if (!_syncServer) return c.json({ albums: [] });
const space = c.req.param("space") || "demo";
const result = await resolveCallerRole(space, claims);
if (!result || !roleAtLeast(result.role, minRole)) {
return c.json({ error: "Insufficient permissions" }, 403);
}
return { claims, role: result.role, space };
}
// Resolve caller role for membrane filtering
let callerRole: SpaceRoleString = 'viewer';
/** Resolve caller role without requiring auth (for membrane filtering on read routes). */
async function resolveOptionalRole(c: any): Promise<{ role: SpaceRoleString; space: string }> {
const space = c.req.param("space") || "demo";
let role: SpaceRoleString = 'viewer';
const token = extractToken(c.req.raw.headers);
if (token) {
try {
const claims = await verifyToken(token);
const resolved = await resolveCallerRole(space, claims);
if (resolved) callerRole = resolved.role;
if (resolved) role = resolved.role;
} catch {}
}
return { role, space };
}
// ── Setup routes (admin only) ──
routes.get("/api/setup/status", async (c) => {
if (!_syncServer) return c.json({ enabled: false, spaceAlbumId: null });
const { role, space } = await resolveOptionalRole(c);
const doc = ensurePhotosDoc(space);
return c.json({ albums: filterArrayByVisibility(Object.values(doc.sharedAlbums || {}), callerRole) });
return c.json({ enabled: doc.enabled, spaceAlbumId: doc.spaceAlbumId });
});
routes.post("/api/setup", async (c) => {
const auth = await requireRole(c, "admin");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { space } = auth;
const doc = ensurePhotosDoc(space);
if (doc.enabled && doc.spaceAlbumId) {
return c.json({ enabled: true, spaceAlbumId: doc.spaceAlbumId });
}
try {
const album = await immichCreateAlbum(`rspace:${space}`, `Root album for space ${space}`);
const docId = photosDocId(space);
_syncServer.changeDoc<PhotosDoc>(docId, `enable rphotos for ${space}`, (d) => {
d.enabled = true;
d.spaceAlbumId = album.id;
});
return c.json({ enabled: true, spaceAlbumId: album.id }, 201);
} catch (err: any) {
return c.json({ error: `Failed to create Immich album: ${err.message}` }, 500);
}
});
routes.delete("/api/setup", async (c) => {
const auth = await requireRole(c, "admin");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { space } = auth;
const docId = photosDocId(space);
_syncServer.changeDoc<PhotosDoc>(docId, `disable rphotos for ${space}`, (d) => {
d.enabled = false;
});
return c.json({ ok: true });
});
// ── Upload route (member+) ──
routes.post("/api/upload", async (c) => {
const auth = await requireRole(c, "member");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { space } = auth;
const doc = ensurePhotosDoc(space);
if (!doc.enabled || !doc.spaceAlbumId) {
return c.json({ error: "rPhotos is not enabled for this space" }, 400);
}
try {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || !(file instanceof File)) {
return c.json({ error: "file required" }, 400);
}
const formData = new FormData();
formData.append('assetData', file, file.name);
formData.append('deviceAssetId', crypto.randomUUID());
formData.append('deviceId', `rspace-${space}`);
formData.append('fileCreatedAt', new Date().toISOString());
formData.append('fileModifiedAt', new Date().toISOString());
const asset = await immichUploadAsset(formData);
await immichAddAssetsToAlbum(doc.spaceAlbumId, [asset.id]);
return c.json({ id: asset.id, status: asset.status }, 201);
} catch (err: any) {
return c.json({ error: `Upload failed: ${err.message}` }, 500);
}
});
// ── Delete asset (moderator+) ──
routes.delete("/api/assets/:id", async (c) => {
const auth = await requireRole(c, "moderator");
if (auth instanceof Response) return auth;
const assetId = c.req.param("id");
try {
await immichDeleteAssets([assetId]);
return c.json({ ok: true });
} catch (err: any) {
return c.json({ error: `Delete failed: ${err.message}` }, 500);
}
});
// ── Sub-album CRUD ──
routes.post("/api/space-albums", async (c) => {
const auth = await requireRole(c, "member");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { space, claims } = auth;
const doc = ensurePhotosDoc(space);
if (!doc.enabled) return c.json({ error: "rPhotos is not enabled for this space" }, 400);
const { name } = await c.req.json();
if (!name) return c.json({ error: "name required" }, 400);
try {
const immichAlbum = await immichCreateAlbum(`rspace:${space}:${name}`, `Sub-album in space ${space}`);
const id = crypto.randomUUID();
const callerDid = (claims.did as string) || claims.sub || '';
const docId = photosDocId(space);
_syncServer.changeDoc<PhotosDoc>(docId, `create sub-album ${name}`, (d) => {
d.subAlbums[id] = {
id,
immichAlbumId: immichAlbum.id,
name,
createdBy: callerDid,
createdAt: Date.now(),
};
});
const updated = _syncServer.getDoc<PhotosDoc>(docId)!;
return c.json(updated.subAlbums[id], 201);
} catch (err: any) {
return c.json({ error: `Failed to create album: ${err.message}` }, 500);
}
});
routes.patch("/api/space-albums/:id", async (c) => {
const auth = await requireRole(c, "member");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { space, claims, role } = auth;
const albumId = c.req.param("id");
const doc = ensurePhotosDoc(space);
const album = doc.subAlbums[albumId];
if (!album) return c.json({ error: "Album not found" }, 404);
const callerDid = (claims.did as string) || claims.sub || '';
const isOwner = album.createdBy === callerDid;
if (!isOwner && !roleAtLeast(role, "moderator")) {
return c.json({ error: "Insufficient permissions" }, 403);
}
const body = await c.req.json();
const docId = photosDocId(space);
_syncServer.changeDoc<PhotosDoc>(docId, `update sub-album ${albumId}`, (d) => {
if (body.name !== undefined) d.subAlbums[albumId].name = body.name;
if (body.visibility !== undefined) d.subAlbums[albumId].visibility = body.visibility;
});
const updated = _syncServer.getDoc<PhotosDoc>(docId)!;
return c.json(updated.subAlbums[albumId]);
});
routes.delete("/api/space-albums/:id", async (c) => {
const auth = await requireRole(c, "moderator");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { space } = auth;
const albumId = c.req.param("id");
const doc = ensurePhotosDoc(space);
if (!doc.subAlbums[albumId]) return c.json({ error: "Album not found" }, 404);
const docId = photosDocId(space);
_syncServer.changeDoc<PhotosDoc>(docId, `delete sub-album ${albumId}`, (d) => {
delete d.subAlbums[albumId];
});
return c.json({ ok: true });
});
// ── CRUD: Curated Albums ──
routes.get("/api/curations", async (c) => {
if (!_syncServer) return c.json({ albums: [] });
const { role, space } = await resolveOptionalRole(c);
const doc = ensurePhotosDoc(space);
return c.json({ albums: filterArrayByVisibility(Object.values(doc.sharedAlbums || {}), role) });
});
routes.post("/api/curations", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const auth = await requireRole(c, "member");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const { space, claims } = auth;
const { name, description = "" } = await c.req.json();
if (!name) return c.json({ error: "name required" }, 400);
const id = crypto.randomUUID();
@ -83,11 +292,11 @@ routes.post("/api/curations", async (c) => {
});
routes.delete("/api/curations/:albumId", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const auth = await requireRole(c, "moderator");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const { space } = auth;
const albumId = c.req.param("albumId");
const docId = photosDocId(space);
const doc = ensurePhotosDoc(space);
@ -98,20 +307,19 @@ routes.delete("/api/curations/:albumId", async (c) => {
// ── CRUD: Photo Annotations ──
routes.get("/api/annotations", (c) => {
routes.get("/api/annotations", async (c) => {
if (!_syncServer) return c.json({ annotations: [] });
const space = c.req.param("space") || "demo";
const { space } = await resolveOptionalRole(c);
const doc = ensurePhotosDoc(space);
return c.json({ annotations: Object.values(doc.annotations || {}) });
});
routes.post("/api/annotations", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const auth = await requireRole(c, "member");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const { space, claims } = auth;
const { assetId, note } = await c.req.json();
if (!assetId || !note) return c.json({ error: "assetId and note required" }, 400);
const id = crypto.randomUUID();
@ -125,11 +333,11 @@ routes.post("/api/annotations", async (c) => {
});
routes.delete("/api/annotations/:annotationId", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const auth = await requireRole(c, "member");
if (auth instanceof Response) return auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const { space } = auth;
const annotationId = c.req.param("annotationId");
const docId = photosDocId(space);
const doc = ensurePhotosDoc(space);
@ -137,73 +345,73 @@ routes.delete("/api/annotations/:annotationId", async (c) => {
_syncServer.changeDoc<PhotosDoc>(docId, `delete annotation ${annotationId}`, (d) => { delete d.annotations[annotationId]; });
return c.json({ ok: true });
});
const IMMICH_API_KEY = process.env.RPHOTOS_API_KEY || "";
const IMMICH_PUBLIC_URL = process.env.RPHOTOS_IMMICH_PUBLIC_URL || "https://demo.rphotos.online";
// ── Proxy: list shared albums ──
// ── Proxy: list space albums (space-scoped) ──
routes.get("/api/albums", async (c) => {
try {
const res = await fetch(`${IMMICH_BASE}/api/albums?shared=true`, {
headers: { "x-api-key": IMMICH_API_KEY },
});
if (!res.ok) return c.json({ albums: [] });
const albums = await res.json();
return c.json({ albums });
} catch {
if (!_syncServer) return c.json({ albums: [] });
const { role, space } = await resolveOptionalRole(c);
const doc = ensurePhotosDoc(space);
if (!doc.enabled || !doc.spaceAlbumId) {
return c.json({ albums: [] });
}
const subAlbums = filterArrayByVisibility(Object.values(doc.subAlbums || {}), role);
return c.json({ albums: subAlbums });
});
// ── Proxy: album detail with assets ──
// ── Proxy: album detail (space-scoped) ──
routes.get("/api/albums/:id", async (c) => {
const id = c.req.param("id");
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const { role, space } = await resolveOptionalRole(c);
const albumId = c.req.param("id");
const doc = ensurePhotosDoc(space);
// Verify album belongs to this space
const subAlbum = doc.subAlbums[albumId];
if (!subAlbum) return c.json({ error: "Album not found" }, 404);
try {
const res = await fetch(`${IMMICH_BASE}/api/albums/${id}`, {
headers: { "x-api-key": IMMICH_API_KEY },
});
if (!res.ok) return c.json({ error: "Album not found" }, 404);
return c.json(await res.json());
const data = await immichGetAlbum(subAlbum.immichAlbumId);
if (!data) return c.json({ error: "Album not found in Immich" }, 404);
return c.json(data);
} catch {
return c.json({ error: "Failed to load album" }, 500);
}
});
// ── Proxy: recent assets ──
// ── Proxy: recent assets (space-scoped) ──
routes.get("/api/assets", async (c) => {
const size = c.req.query("size") || "50";
if (!_syncServer) return c.json({ assets: [] });
const { space } = await resolveOptionalRole(c);
const size = parseInt(c.req.query("size") || "50");
const doc = ensurePhotosDoc(space);
if (!doc.enabled || !doc.spaceAlbumId) {
return c.json({ assets: [] });
}
try {
const res = await fetch(`${IMMICH_BASE}/api/search/metadata`, {
method: "POST",
headers: {
"x-api-key": IMMICH_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
size: parseInt(size),
order: "desc",
type: "IMAGE",
}),
});
if (!res.ok) return c.json({ assets: [] });
const data = await res.json();
return c.json({ assets: data.assets?.items || [] });
const result = await immichSearchInAlbum(doc.spaceAlbumId, { size });
return c.json({ assets: result.items });
} catch {
return c.json({ assets: [] });
}
});
// ── Proxy: asset thumbnail ──
// ── Proxy: asset thumbnail (viewer+ auth) ──
routes.get("/api/assets/:id/thumbnail", async (c) => {
const id = c.req.param("id");
const size = c.req.query("size") || "thumbnail";
try {
const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/thumbnail?size=${size}`, {
headers: { "x-api-key": IMMICH_API_KEY },
});
if (!res.ok) return c.body(null, 404);
const body = await res.arrayBuffer();
return c.body(body, 200, {
"Content-Type": res.headers.get("Content-Type") || "image/jpeg",
const result = await immichGetAssetThumbnail(id, size);
if (!result) return c.body(null, 404);
return c.body(result.body, 200, {
"Content-Type": result.contentType,
"Cache-Control": "public, max-age=86400",
});
} catch {
@ -211,17 +419,15 @@ routes.get("/api/assets/:id/thumbnail", async (c) => {
}
});
// ── Proxy: full-size asset ──
// ── Proxy: full-size asset (viewer+ auth) ──
routes.get("/api/assets/:id/original", async (c) => {
const id = c.req.param("id");
try {
const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/original`, {
headers: { "x-api-key": IMMICH_API_KEY },
});
if (!res.ok) return c.body(null, 404);
const body = await res.arrayBuffer();
return c.body(body, 200, {
"Content-Type": res.headers.get("Content-Type") || "image/jpeg",
const result = await immichGetAssetOriginal(id);
if (!result) return c.body(null, 404);
return c.body(result.body, 200, {
"Content-Type": result.contentType,
"Cache-Control": "public, max-age=86400",
});
} catch {
@ -229,10 +435,24 @@ routes.get("/api/assets/:id/original", async (c) => {
}
});
// ── Embedded Immich UI ──
routes.get("/album", (c) => {
// ── Embedded Immich UI (admin only) ──
routes.get("/album", async (c) => {
const auth = await requireRole(c, "admin");
if (auth instanceof Response) {
// Fallback: render the page but the iframe will be empty for non-admins
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — Photos | rSpace`,
moduleId: "rphotos",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<div style="text-align:center;padding:3rem;color:#64748b">Admin access required to use the Immich interface directly.</div>`,
}));
}
const spaceSlug = auth.space;
return c.html(renderExternalAppShell({
title: `${spaceSlug} — Immich | rSpace`,
moduleId: "rphotos",
@ -245,9 +465,9 @@ routes.get("/album", (c) => {
});
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — Photos | rSpace`,
moduleId: "rphotos",
@ -255,7 +475,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js?v=2"></script>`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
}));
});

View File

@ -21,6 +21,15 @@ export interface SharedAlbum {
visibility?: import('../../shared/membrane').ObjectVisibility;
}
export interface SpaceAlbum {
id: string;
immichAlbumId: string;
name: string;
createdBy: string;
createdAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;
}
export interface PhotoAnnotation {
assetId: string;
note: string;
@ -36,6 +45,9 @@ export interface PhotosDoc {
spaceSlug: string;
createdAt: number;
};
enabled: boolean;
spaceAlbumId: string | null;
subAlbums: Record<string, SpaceAlbum>;
sharedAlbums: Record<string, SharedAlbum>;
annotations: Record<string, PhotoAnnotation>;
}
@ -45,22 +57,28 @@ export interface PhotosDoc {
export const photosSchema: DocSchema<PhotosDoc> = {
module: 'photos',
collection: 'albums',
version: 1,
version: 2,
init: (): PhotosDoc => ({
meta: {
module: 'photos',
collection: 'albums',
version: 1,
version: 2,
spaceSlug: '',
createdAt: Date.now(),
},
enabled: false,
spaceAlbumId: null,
subAlbums: {},
sharedAlbums: {},
annotations: {},
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.sharedAlbums) doc.sharedAlbums = {};
if (!doc.annotations) doc.annotations = {};
doc.meta.version = 1;
if (doc.enabled === undefined) doc.enabled = false;
if (doc.spaceAlbumId === undefined) doc.spaceAlbumId = null;
if (!doc.subAlbums) doc.subAlbums = {};
doc.meta.version = 2;
return doc;
},
};