feat(rphotos): per-space Immich isolation with RBAC permissions
Each space now gets its own Immich album with role-gated CRUD: - Admin: enable/disable rPhotos, access Immich embed - Member+: upload photos, create sub-albums - Moderator+: delete photos, manage any sub-album - Viewer: browse gallery (read-only) New immich-client.ts centralizes all Immich API calls. Schema v2 adds enabled, spaceAlbumId, and subAlbums fields with migration. Frontend sends auth headers on all API calls and shows role-appropriate UI (setup prompt, upload button, delete in lightbox). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58586334bf
commit
09c06692b0
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue