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.
|
* <folk-photo-gallery> — Community photo gallery powered by Immich.
|
||||||
*
|
*
|
||||||
* Browse shared albums, view recent photos, and open the full
|
* Role-aware: upload (member+), delete (moderator+), Immich embed (admin).
|
||||||
* Immich interface for uploads and management.
|
* Shows setup prompt for admins when rPhotos isn't enabled for the space.
|
||||||
*
|
*
|
||||||
* Attributes:
|
* Attributes:
|
||||||
* space — space slug (default: "demo")
|
* space — space slug (default: "demo")
|
||||||
|
|
@ -15,6 +15,16 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
import { photosSchema, photosDocId } from "../schemas";
|
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 {
|
interface Album {
|
||||||
id: string;
|
id: string;
|
||||||
albumName: string;
|
albumName: string;
|
||||||
|
|
@ -39,18 +49,27 @@ interface Asset {
|
||||||
fileCreatedAt: string;
|
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 {
|
class FolkPhotoGallery extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "demo";
|
private space = "demo";
|
||||||
private view: "gallery" | "album" | "lightbox" = "gallery";
|
private view: "gallery" | "album" | "lightbox" = "gallery";
|
||||||
private albums: Album[] = [];
|
private albums: (Album | SpaceAlbumInfo)[] = [];
|
||||||
private assets: Asset[] = [];
|
private assets: Asset[] = [];
|
||||||
private albumAssets: Asset[] = [];
|
private albumAssets: Asset[] = [];
|
||||||
private selectedAlbum: Album | null = null;
|
private selectedAlbum: (Album | SpaceAlbumInfo) | null = null;
|
||||||
private lightboxAsset: Asset | null = null;
|
private lightboxAsset: Asset | null = null;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private error = "";
|
private error = "";
|
||||||
private showingSampleData = false;
|
private showingSampleData = false;
|
||||||
|
private userRole: UserRole = null;
|
||||||
|
private spaceEnabled = false;
|
||||||
|
private uploading = false;
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private _stopPresence: (() => void) | null = null;
|
private _stopPresence: (() => void) | null = null;
|
||||||
private _offlineUnsub: (() => void) | null = null;
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
@ -59,7 +78,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
private static readonly TOUR_STEPS = [
|
private static readonly TOUR_STEPS = [
|
||||||
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
|
{ 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: '.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() {
|
constructor() {
|
||||||
|
|
@ -78,13 +97,13 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
} else {
|
} else {
|
||||||
this.loadGallery();
|
this.loadSetupAndGallery();
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
}
|
}
|
||||||
if (!localStorage.getItem("rphotos_tour_done")) {
|
if (!localStorage.getItem("rphotos_tour_done")) {
|
||||||
setTimeout(() => this._tour.start(), 1200);
|
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);
|
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +137,54 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
} catch { /* runtime unavailable */ }
|
} 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() {
|
private loadDemoData() {
|
||||||
|
this.userRole = 'viewer';
|
||||||
this.albums = [
|
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-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 },
|
{ 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`;
|
return `${this.getApiBase()}/album`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getAlbumName(album: Album | SpaceAlbumInfo): string {
|
||||||
|
return 'albumName' in album ? album.albumName : album.name;
|
||||||
|
}
|
||||||
|
|
||||||
private async loadGallery() {
|
private async loadGallery() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -188,8 +258,8 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
try {
|
try {
|
||||||
const [albumsRes, assetsRes] = await Promise.all([
|
const [albumsRes, assetsRes] = await Promise.all([
|
||||||
fetch(`${base}/api/albums`),
|
this.authFetch(`${base}/api/albums`),
|
||||||
fetch(`${base}/api/assets?size=24`),
|
this.authFetch(`${base}/api/assets?size=24`),
|
||||||
]);
|
]);
|
||||||
const albumsData = await albumsRes.json();
|
const albumsData = await albumsRes.json();
|
||||||
const assetsData = await assetsRes.json();
|
const assetsData = await assetsRes.json();
|
||||||
|
|
@ -199,7 +269,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
// Fall through to empty check below
|
// 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.showingSampleData = true;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
|
|
@ -210,7 +280,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAlbum(album: Album) {
|
private async loadAlbum(album: Album | SpaceAlbumInfo) {
|
||||||
this.selectedAlbum = album;
|
this.selectedAlbum = album;
|
||||||
this.view = "album";
|
this.view = "album";
|
||||||
|
|
||||||
|
|
@ -225,7 +295,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base = this.getApiBase();
|
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();
|
const data = await res.json();
|
||||||
this.albumAssets = data.assets || [];
|
this.albumAssets = data.assets || [];
|
||||||
} catch {
|
} 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() {
|
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 = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
|
: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:hover { background: #6366f1; }
|
||||||
.rapp-nav__btn--secondary { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #94a3b8; }
|
.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--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; }
|
.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; }
|
.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;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.lightbox-close:hover { background: rgba(255,255,255,0.2); }
|
.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 {
|
.lightbox-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
|
|
@ -390,6 +574,17 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
.empty h3 { color: #94a3b8; margin: 0 0 0.5rem; }
|
.empty h3 { color: #94a3b8; margin: 0 0 0.5rem; }
|
||||||
.empty p { margin: 0 0 1.5rem; font-size: 0.9rem; }
|
.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; }
|
.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; }
|
.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.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
||||||
${this.loading ? '<div class="loading">Loading photos...</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() : ""}
|
${this.view === "lightbox" && this.lightboxAsset ? this.renderLightbox() : ""}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -446,21 +642,46 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderView(): string {
|
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();
|
if (this.view === "album" && this.selectedAlbum) return this.renderAlbum();
|
||||||
return this.renderGalleryView();
|
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 {
|
private renderGalleryView(): string {
|
||||||
const hasContent = this.albums.length > 0 || this.assets.length > 0;
|
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 `
|
return `
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<span class="rapp-nav__title">Photos</span>
|
<span class="rapp-nav__title">Photos</span>
|
||||||
<div class="rapp-nav__actions">
|
<div class="rapp-nav__actions">
|
||||||
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
${canUpload ? '<button class="rapp-nav__btn" id="btn-upload">Upload</button>' : ''}
|
||||||
Open Immich
|
${isAdmin ? `<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">Open Immich</a>` : ''}
|
||||||
</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>
|
||||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -470,32 +691,33 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<div class="empty-icon">📸</div>
|
<div class="empty-icon">📸</div>
|
||||||
<h3>No photos yet</h3>
|
<h3>No photos yet</h3>
|
||||||
<p>Upload photos through Immich to see them here. Shared albums will appear automatically.</p>
|
<p>${canUpload ? 'Upload the first photo to get started!' : 'Photos uploaded by members will appear here.'}</p>
|
||||||
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
${canUpload ? '<button class="rapp-nav__btn" id="btn-upload-empty">Upload Photos</button>' : ''}
|
||||||
Open Immich to Upload
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
|
|
||||||
${this.albums.length > 0 ? `
|
${this.albums.length > 0 ? `
|
||||||
<div class="albums-section">
|
<div class="albums-section">
|
||||||
<div class="section-title">Shared Albums</div>
|
<div class="section-title">Albums</div>
|
||||||
<div class="albums-grid">
|
<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-card" data-album-id="${a.id}" data-collab-id="album:${a.id}">
|
||||||
<div class="album-thumb">
|
<div class="album-thumb">
|
||||||
${this.isDemo()
|
${this.isDemo()
|
||||||
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
|
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(name)}</div>`
|
||||||
: a.albumThumbnailAssetId
|
: isImmichAlbum && (a as Album).albumThumbnailAssetId
|
||||||
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
|
? `<img src="${this.thumbUrl((a as Album).albumThumbnailAssetId!)}" alt="${this.esc(name)}" loading="lazy">`
|
||||||
: '<span class="album-thumb-empty">📷</span>'}
|
: '<span class="album-thumb-empty">📷</span>'}
|
||||||
</div>
|
</div>
|
||||||
<div class="album-info">
|
<div class="album-info">
|
||||||
<div class="album-name">${this.esc(a.albumName)}</div>
|
<div class="album-name">${this.esc(name)}</div>
|
||||||
<div class="album-meta">${a.assetCount} photo${a.assetCount !== 1 ? "s" : ""}</div>
|
${isImmichAlbum ? `<div class="album-meta">${(a as Album).assetCount} photo${(a as Album).assetCount !== 1 ? "s" : ""}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join("")}
|
`;}).join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
|
|
@ -517,14 +739,14 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
|
|
||||||
private renderAlbum(): string {
|
private renderAlbum(): string {
|
||||||
const album = this.selectedAlbum!;
|
const album = this.selectedAlbum!;
|
||||||
|
const name = this.getAlbumName(album);
|
||||||
|
const isAdmin = roleAtLeast(this.userRole, 'admin');
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="gallery">\u2190 Photos</button>' : ''}
|
${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">
|
<div class="rapp-nav__actions">
|
||||||
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
|
${isAdmin ? `<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">Open in Immich</a>` : ''}
|
||||||
Open in Immich
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -532,7 +754,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<div class="empty-icon">📷</div>
|
<div class="empty-icon">📷</div>
|
||||||
<h3>Album is empty</h3>
|
<h3>Album is empty</h3>
|
||||||
<p>Add photos to this album in Immich.</p>
|
<p>Upload photos to populate this album.</p>
|
||||||
</div>
|
</div>
|
||||||
` : `
|
` : `
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
|
|
@ -553,6 +775,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
const info = asset.exifInfo;
|
const info = asset.exifInfo;
|
||||||
const location = [info?.city, info?.country].filter(Boolean).join(", ");
|
const location = [info?.city, info?.country].filter(Boolean).join(", ");
|
||||||
const camera = [info?.make, info?.model].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 demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
|
||||||
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
|
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
|
||||||
|
|
@ -560,6 +783,11 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
return `
|
return `
|
||||||
<div class="lightbox" data-lightbox>
|
<div class="lightbox" data-lightbox>
|
||||||
<button class="lightbox-close" data-close-lightbox>✕</button>
|
<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
|
${demoMeta
|
||||||
? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>`
|
? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>`
|
||||||
: `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">`}
|
: `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">`}
|
||||||
|
|
@ -575,6 +803,18 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
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
|
// Album cards
|
||||||
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {
|
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.
|
* Photos module — community photo commons powered by Immich.
|
||||||
*
|
*
|
||||||
* Provides a gallery UI within the rSpace shell that connects to
|
* Per-space isolation: each space gets its own Immich album, with
|
||||||
* the Immich instance at {space}.rphotos.online. Proxies API requests
|
* CRUD gated by the space role system (viewer/member/moderator/admin).
|
||||||
* for albums and thumbnails to avoid CORS issues.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
@ -12,19 +11,30 @@ import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } from "../../shared/module";
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
import { verifyToken, extractToken } from "../../server/auth";
|
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 type { SpaceRoleString } from "../../server/spaces";
|
||||||
import { filterArrayByVisibility } from "../../shared/membrane";
|
import { filterArrayByVisibility } from "../../shared/membrane";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
import { photosSchema, photosDocId } from './schemas';
|
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;
|
let _syncServer: SyncServer | null = null;
|
||||||
|
|
||||||
const routes = new Hono();
|
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 ──
|
// ── Local-first helpers ──
|
||||||
|
|
||||||
|
|
@ -42,34 +52,233 @@ function ensurePhotosDoc(space: string): PhotosDoc {
|
||||||
return doc;
|
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 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
|
/** Resolve caller role without requiring auth (for membrane filtering on read routes). */
|
||||||
let callerRole: SpaceRoleString = 'viewer';
|
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);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const claims = await verifyToken(token);
|
const claims = await verifyToken(token);
|
||||||
const resolved = await resolveCallerRole(space, claims);
|
const resolved = await resolveCallerRole(space, claims);
|
||||||
if (resolved) callerRole = resolved.role;
|
if (resolved) role = resolved.role;
|
||||||
} catch {}
|
} 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);
|
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) => {
|
routes.post("/api/curations", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const auth = await requireRole(c, "member");
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (auth instanceof Response) return auth;
|
||||||
let claims;
|
|
||||||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
||||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
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();
|
const { name, description = "" } = await c.req.json();
|
||||||
if (!name) return c.json({ error: "name required" }, 400);
|
if (!name) return c.json({ error: "name required" }, 400);
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
@ -83,11 +292,11 @@ routes.post("/api/curations", async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
routes.delete("/api/curations/:albumId", async (c) => {
|
routes.delete("/api/curations/:albumId", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const auth = await requireRole(c, "moderator");
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (auth instanceof Response) return auth;
|
||||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
||||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
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 albumId = c.req.param("albumId");
|
||||||
const docId = photosDocId(space);
|
const docId = photosDocId(space);
|
||||||
const doc = ensurePhotosDoc(space);
|
const doc = ensurePhotosDoc(space);
|
||||||
|
|
@ -98,20 +307,19 @@ routes.delete("/api/curations/:albumId", async (c) => {
|
||||||
|
|
||||||
// ── CRUD: Photo Annotations ──
|
// ── CRUD: Photo Annotations ──
|
||||||
|
|
||||||
routes.get("/api/annotations", (c) => {
|
routes.get("/api/annotations", async (c) => {
|
||||||
if (!_syncServer) return c.json({ annotations: [] });
|
if (!_syncServer) return c.json({ annotations: [] });
|
||||||
const space = c.req.param("space") || "demo";
|
const { space } = await resolveOptionalRole(c);
|
||||||
const doc = ensurePhotosDoc(space);
|
const doc = ensurePhotosDoc(space);
|
||||||
return c.json({ annotations: Object.values(doc.annotations || {}) });
|
return c.json({ annotations: Object.values(doc.annotations || {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
routes.post("/api/annotations", async (c) => {
|
routes.post("/api/annotations", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const auth = await requireRole(c, "member");
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (auth instanceof Response) return auth;
|
||||||
let claims;
|
|
||||||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
||||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
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();
|
const { assetId, note } = await c.req.json();
|
||||||
if (!assetId || !note) return c.json({ error: "assetId and note required" }, 400);
|
if (!assetId || !note) return c.json({ error: "assetId and note required" }, 400);
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
@ -125,11 +333,11 @@ routes.post("/api/annotations", async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
routes.delete("/api/annotations/:annotationId", async (c) => {
|
routes.delete("/api/annotations/:annotationId", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const auth = await requireRole(c, "member");
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (auth instanceof Response) return auth;
|
||||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
||||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
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 annotationId = c.req.param("annotationId");
|
||||||
const docId = photosDocId(space);
|
const docId = photosDocId(space);
|
||||||
const doc = ensurePhotosDoc(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]; });
|
_syncServer.changeDoc<PhotosDoc>(docId, `delete annotation ${annotationId}`, (d) => { delete d.annotations[annotationId]; });
|
||||||
return c.json({ ok: true });
|
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) => {
|
routes.get("/api/albums", async (c) => {
|
||||||
try {
|
if (!_syncServer) return c.json({ albums: [] });
|
||||||
const res = await fetch(`${IMMICH_BASE}/api/albums?shared=true`, {
|
const { role, space } = await resolveOptionalRole(c);
|
||||||
headers: { "x-api-key": IMMICH_API_KEY },
|
const doc = ensurePhotosDoc(space);
|
||||||
});
|
|
||||||
if (!res.ok) return c.json({ albums: [] });
|
if (!doc.enabled || !doc.spaceAlbumId) {
|
||||||
const albums = await res.json();
|
|
||||||
return c.json({ albums });
|
|
||||||
} catch {
|
|
||||||
return c.json({ albums: [] });
|
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) => {
|
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 {
|
try {
|
||||||
const res = await fetch(`${IMMICH_BASE}/api/albums/${id}`, {
|
const data = await immichGetAlbum(subAlbum.immichAlbumId);
|
||||||
headers: { "x-api-key": IMMICH_API_KEY },
|
if (!data) return c.json({ error: "Album not found in Immich" }, 404);
|
||||||
});
|
return c.json(data);
|
||||||
if (!res.ok) return c.json({ error: "Album not found" }, 404);
|
|
||||||
return c.json(await res.json());
|
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: "Failed to load album" }, 500);
|
return c.json({ error: "Failed to load album" }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Proxy: recent assets ──
|
// ── Proxy: recent assets (space-scoped) ──
|
||||||
|
|
||||||
routes.get("/api/assets", async (c) => {
|
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 {
|
try {
|
||||||
const res = await fetch(`${IMMICH_BASE}/api/search/metadata`, {
|
const result = await immichSearchInAlbum(doc.spaceAlbumId, { size });
|
||||||
method: "POST",
|
return c.json({ assets: result.items });
|
||||||
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 || [] });
|
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ assets: [] });
|
return c.json({ assets: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Proxy: asset thumbnail ──
|
// ── Proxy: asset thumbnail (viewer+ auth) ──
|
||||||
|
|
||||||
routes.get("/api/assets/:id/thumbnail", async (c) => {
|
routes.get("/api/assets/:id/thumbnail", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const size = c.req.query("size") || "thumbnail";
|
const size = c.req.query("size") || "thumbnail";
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/thumbnail?size=${size}`, {
|
const result = await immichGetAssetThumbnail(id, size);
|
||||||
headers: { "x-api-key": IMMICH_API_KEY },
|
if (!result) return c.body(null, 404);
|
||||||
});
|
return c.body(result.body, 200, {
|
||||||
if (!res.ok) return c.body(null, 404);
|
"Content-Type": result.contentType,
|
||||||
const body = await res.arrayBuffer();
|
|
||||||
return c.body(body, 200, {
|
|
||||||
"Content-Type": res.headers.get("Content-Type") || "image/jpeg",
|
|
||||||
"Cache-Control": "public, max-age=86400",
|
"Cache-Control": "public, max-age=86400",
|
||||||
});
|
});
|
||||||
} catch {
|
} 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) => {
|
routes.get("/api/assets/:id/original", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/original`, {
|
const result = await immichGetAssetOriginal(id);
|
||||||
headers: { "x-api-key": IMMICH_API_KEY },
|
if (!result) return c.body(null, 404);
|
||||||
});
|
return c.body(result.body, 200, {
|
||||||
if (!res.ok) return c.body(null, 404);
|
"Content-Type": result.contentType,
|
||||||
const body = await res.arrayBuffer();
|
|
||||||
return c.body(body, 200, {
|
|
||||||
"Content-Type": res.headers.get("Content-Type") || "image/jpeg",
|
|
||||||
"Cache-Control": "public, max-age=86400",
|
"Cache-Control": "public, max-age=86400",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -229,10 +435,24 @@ routes.get("/api/assets/:id/original", async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Embedded Immich UI ──
|
// ── Embedded Immich UI (admin only) ──
|
||||||
routes.get("/album", (c) => {
|
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
routes.get("/album", async (c) => {
|
||||||
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
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";
|
||||||
|
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({
|
return c.html(renderExternalAppShell({
|
||||||
title: `${spaceSlug} — Immich | rSpace`,
|
title: `${spaceSlug} — Immich | rSpace`,
|
||||||
moduleId: "rphotos",
|
moduleId: "rphotos",
|
||||||
|
|
@ -245,9 +465,9 @@ routes.get("/album", (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
|
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — Photos | rSpace`,
|
title: `${spaceSlug} — Photos | rSpace`,
|
||||||
moduleId: "rphotos",
|
moduleId: "rphotos",
|
||||||
|
|
@ -255,7 +475,7 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
|
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">`,
|
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export interface SharedAlbum {
|
||||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
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 {
|
export interface PhotoAnnotation {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
|
@ -36,6 +45,9 @@ export interface PhotosDoc {
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
enabled: boolean;
|
||||||
|
spaceAlbumId: string | null;
|
||||||
|
subAlbums: Record<string, SpaceAlbum>;
|
||||||
sharedAlbums: Record<string, SharedAlbum>;
|
sharedAlbums: Record<string, SharedAlbum>;
|
||||||
annotations: Record<string, PhotoAnnotation>;
|
annotations: Record<string, PhotoAnnotation>;
|
||||||
}
|
}
|
||||||
|
|
@ -45,22 +57,28 @@ export interface PhotosDoc {
|
||||||
export const photosSchema: DocSchema<PhotosDoc> = {
|
export const photosSchema: DocSchema<PhotosDoc> = {
|
||||||
module: 'photos',
|
module: 'photos',
|
||||||
collection: 'albums',
|
collection: 'albums',
|
||||||
version: 1,
|
version: 2,
|
||||||
init: (): PhotosDoc => ({
|
init: (): PhotosDoc => ({
|
||||||
meta: {
|
meta: {
|
||||||
module: 'photos',
|
module: 'photos',
|
||||||
collection: 'albums',
|
collection: 'albums',
|
||||||
version: 1,
|
version: 2,
|
||||||
spaceSlug: '',
|
spaceSlug: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
enabled: false,
|
||||||
|
spaceAlbumId: null,
|
||||||
|
subAlbums: {},
|
||||||
sharedAlbums: {},
|
sharedAlbums: {},
|
||||||
annotations: {},
|
annotations: {},
|
||||||
}),
|
}),
|
||||||
migrate: (doc: any, _fromVersion: number) => {
|
migrate: (doc: any, _fromVersion: number) => {
|
||||||
if (!doc.sharedAlbums) doc.sharedAlbums = {};
|
if (!doc.sharedAlbums) doc.sharedAlbums = {};
|
||||||
if (!doc.annotations) doc.annotations = {};
|
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;
|
return doc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue