/** * — Community photo gallery powered by Immich. * * Browse shared albums, view recent photos, and open the full * Immich interface for uploads and management. * * Attributes: * space — space slug (default: "demo") */ interface Album { id: string; albumName: string; description: string; assetCount: number; albumThumbnailAssetId: string | null; updatedAt: string; shared: boolean; } interface Asset { id: string; type: string; originalFileName: string; exifInfo?: { city?: string; country?: string; dateTimeOriginal?: string; make?: string; model?: string; }; fileCreatedAt: string; } class FolkPhotoGallery extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private view: "gallery" | "album" | "lightbox" = "gallery"; private albums: Album[] = []; private assets: Asset[] = []; private albumAssets: Asset[] = []; private selectedAlbum: Album | null = null; private lightboxAsset: Asset | null = null; private loading = false; private error = ""; private showingSampleData = false; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadGallery(); } private loadDemoData() { 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 }, { id: "demo-album-3", albumName: "Nature Walks", description: "Exploring local ecosystems", assetCount: 15, albumThumbnailAssetId: null, updatedAt: "2026-02-20T09:15:00Z", shared: true }, ]; this.assets = [ { id: "demo-asset-1", type: "IMAGE", originalFileName: "sunrise-over-commons.jpg", fileCreatedAt: "2026-02-25T06:30:00Z", exifInfo: { city: "Portland", country: "USA", make: "Fujifilm", model: "X-T5" } }, { id: "demo-asset-2", type: "IMAGE", originalFileName: "workshop-group-photo.jpg", fileCreatedAt: "2026-02-24T15:00:00Z", exifInfo: { city: "Portland", country: "USA" } }, { id: "demo-asset-3", type: "IMAGE", originalFileName: "mycelium-closeup.jpg", fileCreatedAt: "2026-02-23T11:20:00Z", exifInfo: { make: "Canon", model: "EOS R5" } }, { id: "demo-asset-4", type: "IMAGE", originalFileName: "community-garden.jpg", fileCreatedAt: "2026-02-22T09:45:00Z", exifInfo: { city: "Seattle", country: "USA" } }, { id: "demo-asset-5", type: "IMAGE", originalFileName: "maker-space-tools.jpg", fileCreatedAt: "2026-02-21T14:10:00Z", exifInfo: {} }, { id: "demo-asset-6", type: "IMAGE", originalFileName: "sunset-gathering.jpg", fileCreatedAt: "2026-02-20T18:30:00Z", exifInfo: { city: "Vancouver", country: "Canada", make: "Sony", model: "A7IV" } }, { id: "demo-asset-7", type: "IMAGE", originalFileName: "seed-library.jpg", fileCreatedAt: "2026-02-19T10:00:00Z", exifInfo: {} }, { id: "demo-asset-8", type: "IMAGE", originalFileName: "potluck-spread.jpg", fileCreatedAt: "2026-02-18T12:00:00Z", exifInfo: { city: "Portland", country: "USA" } }, ]; this.render(); } private getDemoAssetMeta(id: string): { width: number; height: number; color: string } { const meta: Record = { "demo-asset-1": { width: 4000, height: 2667, color: "#f59e0b" }, "demo-asset-2": { width: 3200, height: 2400, color: "#6366f1" }, "demo-asset-3": { width: 2400, height: 2400, color: "#22c55e" }, "demo-asset-4": { width: 3600, height: 2400, color: "#10b981" }, "demo-asset-5": { width: 2800, height: 1867, color: "#8b5cf6" }, "demo-asset-6": { width: 4000, height: 2667, color: "#ef4444" }, "demo-asset-7": { width: 2000, height: 2000, color: "#14b8a6" }, "demo-asset-8": { width: 3200, height: 2133, color: "#f97316" }, }; return meta[id] || { width: 2000, height: 2000, color: "#64748b" }; } private getDemoAlbumColor(id: string): string { const colors: Record = { "demo-album-1": "#6366f1", "demo-album-2": "#22c55e", "demo-album-3": "#f59e0b", }; return colors[id] || "#64748b"; } private getDemoAlbumAssets(albumId: string): Asset[] { if (albumId === "demo-album-1") return this.assets.slice(0, 6); if (albumId === "demo-album-2") return this.assets.slice(2, 6); if (albumId === "demo-album-3") return this.assets.slice(0, 8); return []; } private isDemo(): boolean { return this.space === "demo" || this.showingSampleData; } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rphotos/); return match ? match[0] : ""; } private getImmichUrl(): string { return `${this.getApiBase()}/album`; } private async loadGallery() { this.loading = true; this.render(); const base = this.getApiBase(); try { const [albumsRes, assetsRes] = await Promise.all([ fetch(`${base}/api/albums`), fetch(`${base}/api/assets?size=24`), ]); const albumsData = await albumsRes.json(); const assetsData = await assetsRes.json(); this.albums = albumsData.albums || []; this.assets = assetsData.assets || []; } catch { // Fall through to empty check below } if (this.albums.length === 0 && this.assets.length === 0) { this.showingSampleData = true; this.loading = false; this.loadDemoData(); return; } this.loading = false; this.render(); } private async loadAlbum(album: Album) { this.selectedAlbum = album; this.view = "album"; if (this.isDemo()) { this.albumAssets = this.getDemoAlbumAssets(album.id); this.render(); return; } this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/albums/${album.id}`); const data = await res.json(); this.albumAssets = data.assets || []; } catch { this.error = "Failed to load album"; } this.loading = false; this.render(); } private openLightbox(asset: Asset) { this.lightboxAsset = asset; this.view = "lightbox"; this.render(); } private closeLightbox() { this.lightboxAsset = null; this.view = this.selectedAlbum ? "album" : "gallery"; this.render(); } private thumbUrl(assetId: string): string { const base = this.getApiBase(); return `${base}/api/assets/${assetId}/thumbnail`; } private originalUrl(assetId: string): string { const base = this.getApiBase(); return `${base}/api/assets/${assetId}/original`; } private formatDate(d: string): string { return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Loading photos...
' : ""} ${!this.loading ? this.renderView() : ""} ${this.view === "lightbox" && this.lightboxAsset ? this.renderLightbox() : ""} `; this.attachListeners(); } private renderView(): string { if (this.view === "album" && this.selectedAlbum) return this.renderAlbum(); return this.renderGalleryView(); } private renderGalleryView(): string { const hasContent = this.albums.length > 0 || this.assets.length > 0; return ` ${this.showingSampleData ? '
Showing sample data — connect Immich to see your photos
' : ''} ${!hasContent ? `
📸

No photos yet

Upload photos through Immich to see them here. Shared albums will appear automatically.

Open Immich to Upload
` : ""} ${this.albums.length > 0 ? `
Shared Albums
${this.albums.map((a) => `
${this.isDemo() ? `
${this.esc(a.albumName)}
` : a.albumThumbnailAssetId ? `${this.esc(a.albumName)}` : '📷'}
${this.esc(a.albumName)}
${a.assetCount} photo${a.assetCount !== 1 ? "s" : ""}
`).join("")}
` : ""} ${this.assets.length > 0 ? `
Recent Photos
${this.assets.map((a) => `
${this.isDemo() ? `
${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}
` : `${this.esc(a.originalFileName)}`}
`).join("")}
` : ""} `; } private renderAlbum(): string { const album = this.selectedAlbum!; return `
${this.esc(album.albumName)}
${this.albumAssets.length === 0 ? `
📷

Album is empty

Add photos to this album in Immich.

` : `
${this.albumAssets.map((a) => `
${this.isDemo() ? `
${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}
` : `${this.esc(a.originalFileName)}`}
`).join("")}
`} `; } private renderLightbox(): string { const asset = this.lightboxAsset!; const info = asset.exifInfo; const location = [info?.city, info?.country].filter(Boolean).join(", "); const camera = [info?.make, info?.model].filter(Boolean).join(" "); const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null; const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "); return ` `; } private attachListeners() { // Album cards this.shadow.querySelectorAll("[data-album-id]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.albumId!; const album = this.albums.find((a) => a.id === id); if (album) this.loadAlbum(album); }); }); // Photo cells this.shadow.querySelectorAll("[data-asset-id]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.assetId!; const assets = this.view === "album" ? this.albumAssets : this.assets; const asset = assets.find((a) => a.id === id); if (asset) this.openLightbox(asset); }); }); // Back button this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", () => { this.selectedAlbum = null; this.albumAssets = []; this.view = "gallery"; this.render(); }); }); // Lightbox close this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.closeLightbox()); this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => { if ((e.target as HTMLElement).matches("[data-lightbox]")) this.closeLightbox(); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-photo-gallery", FolkPhotoGallery);