518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
/**
|
|
* <folk-photo-gallery> — 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 = "";
|
|
|
|
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<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";
|
|
}
|
|
|
|
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 {
|
|
this.error = "Could not connect to photo service. Make sure Immich is running.";
|
|
}
|
|
|
|
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 = `
|
|
<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); }
|
|
|
|
.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;
|
|
}
|
|
.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-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; }
|
|
|
|
.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);
|
|
}
|
|
|
|
@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.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 `
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">Photos</span>
|
|
<div class="rapp-nav__actions">
|
|
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
|
Open Immich
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
${!hasContent ? `
|
|
<div class="empty">
|
|
<div class="empty-icon">📸</div>
|
|
<h3>No photos yet</h3>
|
|
<p>Upload photos through Immich to see them here. Shared albums will appear automatically.</p>
|
|
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
|
Open Immich to Upload
|
|
</a>
|
|
</div>
|
|
` : ""}
|
|
|
|
${this.albums.length > 0 ? `
|
|
<div class="albums-section">
|
|
<div class="section-title">Shared Albums</div>
|
|
<div class="albums-grid">
|
|
${this.albums.map((a) => `
|
|
<div class="album-card" data-album-id="${a.id}">
|
|
<div class="album-thumb">
|
|
${this.isDemo()
|
|
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
|
|
: a.albumThumbnailAssetId
|
|
? `<img src="${this.thumbUrl(a.albumThumbnailAssetId)}" alt="${this.esc(a.albumName)}" loading="lazy">`
|
|
: '<span class="album-thumb-empty">📷</span>'}
|
|
</div>
|
|
<div class="album-info">
|
|
<div class="album-name">${this.esc(a.albumName)}</div>
|
|
<div class="album-meta">${a.assetCount} photo${a.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}">
|
|
${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!;
|
|
return `
|
|
<div class="rapp-nav">
|
|
<button class="rapp-nav__back" data-back="gallery">← Photos</button>
|
|
<span class="rapp-nav__title">${this.esc(album.albumName)}</span>
|
|
<div class="rapp-nav__actions">
|
|
<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>Add photos to this album in Immich.</p>
|
|
</div>
|
|
` : `
|
|
<div class="photo-grid">
|
|
${this.albumAssets.map((a) => `
|
|
<div class="photo-cell" data-asset-id="${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 demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
|
|
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
|
|
|
|
return `
|
|
<div class="lightbox" data-lightbox>
|
|
<button class="lightbox-close" data-close-lightbox>✕</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)}">`}
|
|
<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 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);
|