1032 lines
35 KiB
TypeScript
1032 lines
35 KiB
TypeScript
/**
|
||
* <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 ? ` · ${demoMeta.width}x${demoMeta.height}` : ""}
|
||
${location ? ` · ${this.esc(location)}` : ""}
|
||
${camera ? ` · ${this.esc(camera)}` : ""}
|
||
· ${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);
|