diff --git a/modules/rphotos/components/folk-photo-gallery.ts b/modules/rphotos/components/folk-photo-gallery.ts index b65ae474..9ebc6d15 100644 --- a/modules/rphotos/components/folk-photo-gallery.ts +++ b/modules/rphotos/components/folk-photo-gallery.ts @@ -1,8 +1,8 @@ /** * — 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 = { 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 { + const token = this.getToken(); + return token ? { 'Authorization': `Bearer ${token}` } : {}; + } + + private async authFetch(url: string, opts: RequestInit = {}): Promise { + const h = { ...this.authHeaders(), ...(opts.headers as Record || {}) }; + 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 = `