rspace-online/modules/rphotos/components/folk-photo-gallery.ts

1032 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-photo-gallery> — Community photo gallery powered by Immich.
*
* 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")
*/
import { makeDraggableAll } from "../../../shared/draggable";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
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;
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;
}
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 | SpaceAlbumInfo)[] = [];
private assets: Asset[] = [];
private albumAssets: Asset[] = [];
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;
private _subscribedDocIds: string[] = [];
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery", "rphotos");
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: "Actions", message: "Upload photos, open albums, or manage your gallery.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkPhotoGallery.TOUR_STEPS,
"rphotos_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") {
this.loadDemoData();
} else {
this.loadSetupAndGallery();
this.subscribeOffline();
}
if (!localStorage.getItem("rphotos_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
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);
}
disconnectedCallback() {
this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null;
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
this._history.destroy();
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
}
private _onViewRestored = (e: CustomEvent) => {
if (e.detail?.moduleId !== 'rphotos') return;
this.view = e.detail.view;
if (e.detail.view === "gallery") { this.selectedAlbum = null; this.albumAssets = []; }
this.lightboxAsset = null;
this.render();
};
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = photosDocId(this.space) as DocumentId;
await runtime.subscribe(docId, photosSchema);
this._subscribedDocIds.push(docId);
} 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 },
{ 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<string, { width: number; height: number; color: string }> = {
"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<string, string> = {
"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 getAlbumName(album: Album | SpaceAlbumInfo): string {
return 'albumName' in album ? album.albumName : album.name;
}
private async loadGallery() {
this.loading = true;
this.render();
const base = this.getApiBase();
try {
const [albumsRes, assetsRes] = await Promise.all([
this.authFetch(`${base}/api/albums`),
this.authFetch(`${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.spaceEnabled) {
this.showingSampleData = true;
this.loading = false;
this.loadDemoData();
return;
}
this.loading = false;
this.render();
}
private async loadAlbum(album: Album | SpaceAlbumInfo) {
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 this.authFetch(`${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 currentAssetList(): Asset[] {
return this.selectedAlbum ? this.albumAssets : this.assets;
}
private navigateLightbox(dir: 1 | -1) {
if (!this.lightboxAsset) return;
const list = this.currentAssetList();
const idx = list.findIndex((a) => a.id === this.lightboxAsset!.id);
if (idx < 0) return;
const next = list[idx + dir];
if (next) {
this.lightboxAsset = next;
this._history.push("lightbox", { assetId: next.id });
this.render();
}
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "gallery") {
this.selectedAlbum = null;
this.albumAssets = [];
}
this.lightboxAsset = null;
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",
});
}
// ── 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; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; transition: color 0.15s, border-color 0.15s; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; color: #e2e8f0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rapp-nav__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
.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; }
.albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.album-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s;
}
.album-card:hover { border-color: #f9a8d4; transform: translateY(-2px); }
.album-thumb {
aspect-ratio: 16/10;
background: #0f172a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.album-thumb img { width: 100%; height: 100%; object-fit: cover; }
.album-thumb-empty { font-size: 2.5rem; opacity: 0.3; }
.album-info { padding: 10px 12px; }
.album-name { font-size: 14px; font-weight: 600; color: #f1f5f9; margin-bottom: 2px; }
.album-meta { font-size: 12px; color: #64748b; }
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 4px;
}
.photo-cell {
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
border-radius: 4px;
background: #0f172a;
transition: opacity 0.15s;
}
.photo-cell:hover { opacity: 0.85; }
.photo-cell img { width: 100%; height: 100%; object-fit: cover; }
.lightbox {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0,0,0,0.92);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
}
.lightbox img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 8px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
transform-origin: center center;
transition: transform 0.2s ease;
will-change: transform;
}
.lightbox img.dragging { transition: none; }
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 28px;
width: 48px;
height: 48px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-nav:hover { background: rgba(255,255,255,0.2); }
.lightbox-nav.prev { left: 16px; }
.lightbox-nav.next { right: 16px; }
@media (max-width: 640px) {
.lightbox-nav { display: none; } /* mobile uses swipe */
}
.lightbox-close {
position: absolute;
top: 16px;
right: 16px;
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 24px;
width: 44px;
height: 44px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
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;
font-size: 13px;
}
.empty { text-align: center; color: #64748b; padding: 3rem 1rem; }
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.4; }
.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; }
.demo-thumb {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.85);
text-align: center; padding: 8px;
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
word-break: break-word; line-height: 1.3;
}
.demo-lightbox-img {
width: 80vw; max-width: 900px; aspect-ratio: 3/2;
display: flex; align-items: center; justify-content: center;
border-radius: 8px;
font-size: 20px; font-weight: 600; color: rgba(255,255,255,0.9);
text-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
.sample-banner { padding: 8px 16px; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); border-radius: 8px; color: #a5b4fc; font-size: 13px; text-align: center; margin-bottom: 12px; }
@media (max-width: 480px) {
.albums-grid { grid-template-columns: 1fr; }
.photo-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading">Loading photos...</div>' : ""}
${this.uploading ? '<div class="loading">Uploading...</div>' : ""}
${!this.loading && !this.uploading ? this.renderView() : ""}
${this.view === "lightbox" && this.lightboxAsset ? this.renderLightbox() : ""}
`;
this.attachListeners();
// Make photo cells and album cards draggable for calendar reminders
makeDraggableAll(this.shadow, ".photo-cell[data-asset-id]", (el) => {
const img = el.querySelector("img");
const title = img?.alt || "Photo";
const id = el.dataset.assetId || "";
return { title, module: "rphotos", entityId: id, label: "Photo", color: "#ec4899" };
});
makeDraggableAll(this.shadow, ".album-card[data-album-id]", (el) => {
const title = el.querySelector(".album-name")?.textContent || "Album";
const id = el.dataset.albumId || "";
return { title, module: "rphotos", entityId: id, label: "Album", color: "#ec4899" };
});
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
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">
${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>
${this.showingSampleData ? '<div class="sample-banner">Showing sample data — connect Immich to see your photos</div>' : ''}
${!hasContent ? `
<div class="empty">
<div class="empty-icon">📸</div>
<h3>No photos yet</h3>
<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">Albums</div>
<div class="albums-grid">
${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(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(name)}</div>
${isImmichAlbum ? `<div class="album-meta">${(a as Album).assetCount} photo${(a as Album).assetCount !== 1 ? "s" : ""}</div>` : ''}
</div>
</div>
`;}).join("")}
</div>
</div>
` : ""}
${this.assets.length > 0 ? `
<div class="section-title">Recent Photos</div>
<div class="photo-grid">
${this.assets.map((a) => `
<div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
</div>
`).join("")}
</div>
` : ""}
`;
}
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(name)}</span>
<div class="rapp-nav__actions">
${isAdmin ? `<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">Open in Immich</a>` : ''}
</div>
</div>
${this.albumAssets.length === 0 ? `
<div class="empty">
<div class="empty-icon">📷</div>
<h3>Album is empty</h3>
<p>Upload photos to populate this album.</p>
</div>
` : `
<div class="photo-grid">
${this.albumAssets.map((a) => `
<div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
</div>
`).join("")}
</div>
`}
`;
}
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 canDelete = roleAtLeast(this.userRole, 'moderator') && !this.isDemo();
const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
const list = this.currentAssetList();
const idx = list.findIndex((a) => a.id === asset.id);
const hasPrev = idx > 0;
const hasNext = idx >= 0 && idx < list.length - 1;
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>
` : ''}
${hasPrev ? `<button class="lightbox-nav prev" data-lightbox-prev aria-label="Previous photo"></button>` : ''}
${hasNext ? `<button class="lightbox-nav next" data-lightbox-next aria-label="Next photo"></button>` : ''}
${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)}" data-lightbox-img>`}
<div class="lightbox-info">
${asset.originalFileName}${demoMeta ? ` &middot; ${demoMeta.width}x${demoMeta.height}` : ""}
${location ? ` &middot; ${this.esc(location)}` : ""}
${camera ? ` &middot; ${this.esc(camera)}` : ""}
&middot; ${this.formatDate(asset.fileCreatedAt)}
</div>
</div>
`;
}
private attachLightboxGestures() {
const img = this.shadow.querySelector<HTMLImageElement>("[data-lightbox-img]");
if (!img) return;
// Gesture state
const pointers = new Map<number, { x: number; y: number; startX: number; startY: number }>();
let scale = 1;
let translateX = 0;
let translateY = 0;
let pinchStartDist = 0;
let pinchStartScale = 1;
let singleStartX = 0;
let singleStartY = 0;
let translateStartX = 0;
let translateStartY = 0;
const SWIPE_THRESHOLD = 60;
const SWIPE_MAX_VERTICAL = 80;
const apply = () => {
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
const reset = () => {
scale = 1; translateX = 0; translateY = 0; apply();
};
img.addEventListener("pointerdown", (e) => {
img.setPointerCapture(e.pointerId);
img.classList.add("dragging");
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, startX: e.clientX, startY: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y);
pinchStartScale = scale;
} else if (pointers.size === 1) {
singleStartX = e.clientX;
singleStartY = e.clientY;
translateStartX = translateX;
translateStartY = translateY;
}
});
img.addEventListener("pointermove", (e) => {
const p = pointers.get(e.pointerId);
if (!p) return;
p.x = e.clientX;
p.y = e.clientY;
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchStartDist > 0) {
scale = Math.max(1, Math.min(6, pinchStartScale * (dist / pinchStartDist)));
apply();
}
} else if (pointers.size === 1 && scale > 1) {
// Pan while zoomed in
translateX = translateStartX + (e.clientX - singleStartX);
translateY = translateStartY + (e.clientY - singleStartY);
apply();
}
});
const up = (e: PointerEvent) => {
const p = pointers.get(e.pointerId);
if (!p) return;
const dx = e.clientX - p.startX;
const dy = e.clientY - p.startY;
pointers.delete(e.pointerId);
if (pointers.size === 0) {
img.classList.remove("dragging");
// Swipe gesture — only when not zoomed, single pointer, horizontal dominant
if (scale === 1 && Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dy) < SWIPE_MAX_VERTICAL) {
this.navigateLightbox(dx < 0 ? 1 : -1);
} else if (scale < 1.05) {
reset();
}
}
};
img.addEventListener("pointerup", up);
img.addEventListener("pointercancel", up);
// Double-tap / double-click to toggle zoom
let lastTap = 0;
img.addEventListener("click", (e) => {
const now = Date.now();
if (now - lastTap < 300) {
if (scale === 1) {
scale = 2.5;
const rect = img.getBoundingClientRect();
translateX = (rect.width / 2 - (e.clientX - rect.left)) * (scale - 1);
translateY = (rect.height / 2 - (e.clientY - rect.top)) * (scale - 1);
} else {
reset();
}
apply();
}
lastTap = now;
});
}
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) => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.albumId!;
const album = this.albums.find((a) => a.id === id);
if (album) {
this._history.push(this.view);
this._history.push("album", { albumId: id });
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._history.push(this.view);
this._history.push("lightbox", { assetId: id });
this.openLightbox(asset);
}
});
});
// Back button
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => this.goBack());
});
// Lightbox close
this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.goBack());
this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack();
});
// Lightbox prev/next buttons
this.shadow.querySelector("[data-lightbox-prev]")?.addEventListener("click", (e) => {
e.stopPropagation();
this.navigateLightbox(-1);
});
this.shadow.querySelector("[data-lightbox-next]")?.addEventListener("click", (e) => {
e.stopPropagation();
this.navigateLightbox(1);
});
// Gestures on the lightbox image (pinch-zoom + swipe)
if (this.view === "lightbox" && this.lightboxAsset) {
this.attachLightboxGestures();
}
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-photo-gallery", FolkPhotoGallery);