diff --git a/backlog/tasks/task-53 - Redesign-shared-identity-modal-and-user-dropdown.md b/backlog/tasks/task-53 - Redesign-shared-identity-modal-and-user-dropdown.md new file mode 100644 index 0000000..bfa381e --- /dev/null +++ b/backlog/tasks/task-53 - Redesign-shared-identity-modal-and-user-dropdown.md @@ -0,0 +1,38 @@ +--- +id: TASK-53 +title: Redesign shared identity modal and user dropdown +status: Done +assignee: + - '@claude' +created_date: '2026-02-25 22:59' +labels: + - identity + - ux + - shared-header +dependencies: [] +references: + - shared/components/rstack-identity.ts + - server/shell.ts +priority: high +--- + +## Description + + +Redesign the rstack-identity web component shared across all rApps. Replace the old auth modal with a unified "Sign up / Sign in" landing page, and replace Profile/Recovery dropdown items (which routed to auth.ridentity.online) with in-app account settings modals. + + +## Acceptance Criteria + +- [ ] #1 Auth modal shows unified Sign up / Sign in with stacked passkey buttons +- [ ] #2 Close X button and Powered by EncryptID link to ridentity.online on all modal views +- [ ] #3 Register view has Back button instead of inline toggle +- [ ] #4 Logged-in dropdown shows username header with Add Email, Add Second Device, Add Social Recovery +- [ ] #5 No more navigation to auth.ridentity.online from the header + + +## Final Summary + + +Implemented in commit 0647f19. Auth modal redesigned with unified landing, close buttons, EncryptID branding link. User dropdown restructured with account settings items replacing old Profile/Recovery links. All changes in rstack-identity.ts web component, shared across all 22 rApps via the shell renderer. + diff --git a/backlog/tasks/task-54 - Update-space-switcher-with-emoji-visibility-badges-and-button-styling.md b/backlog/tasks/task-54 - Update-space-switcher-with-emoji-visibility-badges-and-button-styling.md new file mode 100644 index 0000000..5b88d1e --- /dev/null +++ b/backlog/tasks/task-54 - Update-space-switcher-with-emoji-visibility-badges-and-button-styling.md @@ -0,0 +1,35 @@ +--- +id: TASK-54 +title: Update space switcher with emoji visibility badges and button styling +status: Done +assignee: + - '@claude' +created_date: '2026-02-25 22:59' +labels: + - ux + - shared-header +dependencies: [] +references: + - shared/components/rstack-space-switcher.ts +priority: medium +--- + +## Description + + +Update rstack-space-switcher to use emoji visibility indicators (green unlocked, yellow key, red lock) instead of text labels, and match the trigger button styling to the rApp switcher. + + +## Acceptance Criteria + +- [ ] #1 Public spaces show green unlocked emoji +- [ ] #2 Permissioned spaces show yellow key emoji +- [ ] #3 Private spaces show red lock emoji +- [ ] #4 Trigger button matches app-switcher style (font-size, colors, no slash prefix) + + +## Final Summary + + +Implemented in commit 0647f19. Visibility badges changed from text (PUBLIC/PRIVATE/PERMISSIONED) to emojis (πŸ”“/πŸ”‘/πŸ”’) with color-coded backgrounds. Trigger button updated: removed slash prefix, bumped font to 0.9rem, matched app-switcher color scheme. + diff --git a/backlog/tasks/task-55 - Wire-up-account-settings-endpoints-email-verification-device-registration-guardians.md b/backlog/tasks/task-55 - Wire-up-account-settings-endpoints-email-verification-device-registration-guardians.md new file mode 100644 index 0000000..4a6e001 --- /dev/null +++ b/backlog/tasks/task-55 - Wire-up-account-settings-endpoints-email-verification-device-registration-guardians.md @@ -0,0 +1,45 @@ +--- +id: TASK-55 +title: >- + Wire up account settings endpoints (email verification, device registration, + guardians) +status: Done +assignee: + - '@claude' +created_date: '2026-02-25 22:59' +labels: + - identity + - backend + - encryptid +dependencies: [] +references: + - src/encryptid/server.ts + - src/encryptid/db.ts + - shared/components/rstack-identity.ts +priority: high +--- + +## Description + + +Add server-side endpoints for the three account settings features and wire up the client modals to use them. Email verification uses SMTP with 6-digit codes. Device registration uses WebAuthn for same-device passkey addition. Social recovery uses the existing guardian API. + + +## Acceptance Criteria + +- [ ] #1 POST /api/account/email/start sends 6-digit code via SMTP +- [ ] #2 POST /api/account/email/verify validates code and sets email on account +- [ ] #3 POST /api/account/device/start returns WebAuthn creation options for authenticated user +- [ ] #4 POST /api/account/device/complete stores new credential under existing account +- [ ] #5 Social recovery modal loads guardians from GET /api/guardians on open +- [ ] #6 Adding guardian calls POST /api/guardians with name + optional email +- [ ] #7 Removing guardian calls DELETE /api/guardians/:id +- [ ] #8 StoredChallenge.type includes device_registration +- [ ] #9 StoredRecoveryToken.type includes email_verification + + +## Final Summary + + +Implemented in commit 914d0e6. Added 4 new server endpoints under /api/account/ namespace. Email verification sends styled HTML email with 6-digit code via Mailcow SMTP, stores as recovery token. Device registration reuses existing challenge/credential infrastructure with new device_registration type. Client social recovery modal rewritten to use existing guardian API (add/remove individual guardians, load on open, show status). DB types extended for new token/challenge types. + diff --git a/backlog/tasks/task-56 - Consistent-headers-across-all-rApps-mi-AI-assistant.md b/backlog/tasks/task-56 - Consistent-headers-across-all-rApps-mi-AI-assistant.md new file mode 100644 index 0000000..5ca7569 --- /dev/null +++ b/backlog/tasks/task-56 - Consistent-headers-across-all-rApps-mi-AI-assistant.md @@ -0,0 +1,42 @@ +--- +id: TASK-56 +title: Consistent headers across all rApps + mi AI assistant +status: Done +assignee: + - '@claude' +created_date: '2026-02-25 23:10' +labels: + - ux + - shared-header + - ai + - mi +dependencies: [] +references: + - shared/components/rstack-mi.ts + - server/index.ts + - server/shell.ts + - website/public/shell.css +priority: high +--- + +## Description + + +Make all rApp headers identical (fix height mismatches, remove custom overrides) and add the mi AI assistant search bar to every page header. + + +## Acceptance Criteria + +- [ ] #1 All 7 module CSS files use 56px header height (not 52px) +- [ ] #2 No module CSS overrides the shared header background +- [ ] #3 All headers have 3-section layout: left (switchers) / center (mi) / right (identity) +- [ ] #4 rstack-mi component registered in shell.ts and present in all 4 HTML pages + both shell renderers +- [ ] #5 POST /api/mi/ask streams from Ollama with rApp-aware system prompt +- [ ] #6 Fallback responses when Ollama unavailable + + +## Final Summary + + +Implemented in commit 0813eed. Fixed 52pxβ†’56px in 7 module CSS files (pubs, funds, providers, books, swag, choices, cart). Removed header background overrides from books.css and pubs.css. Created rstack-mi web component with streaming chat UI and added to all pages. Server endpoint /api/mi/ask proxies to Ollama with dynamic system prompt built from all 22 registered modules. Keyword-based fallback when AI service is offline. Configurable via MI_MODEL and OLLAMA_URL env vars. + diff --git a/backlog/tasks/task-57 - Layered-tab-system-with-inter-layer-flows-and-bidirectional-feeds.md b/backlog/tasks/task-57 - Layered-tab-system-with-inter-layer-flows-and-bidirectional-feeds.md new file mode 100644 index 0000000..c1f9a5f --- /dev/null +++ b/backlog/tasks/task-57 - Layered-tab-system-with-inter-layer-flows-and-bidirectional-feeds.md @@ -0,0 +1,97 @@ +--- +id: TASK-57 +title: Layered tab system with inter-layer flows and bidirectional feeds +status: Done +assignee: + - '@claude' +created_date: '2026-02-25 23:31' +updated_date: '2026-02-25 23:31' +labels: + - feature + - canvas + - architecture +milestone: rspace-app-ecosystem +dependencies: [] +references: + - lib/layer-types.ts + - lib/folk-feed.ts + - shared/components/rstack-tab-bar.ts + - lib/community-sync.ts + - shared/module.ts + - server/shell.ts + - website/canvas.html + - website/shell.ts + - website/public/shell.css +priority: high +--- + +## Description + + +Implement a layered tab system where each rApp becomes a "layer" that can be viewed as tabs (flat mode) or stacked strata (stack view). Layers connect via typed flows (economic, trust, data, attention, governance, resource) enabling inter-app data sharing. Feed shapes on the canvas pull live data from other layers' APIs with bidirectional write-back support. + +## 4-Phase Implementation + +**Phase 1 β€” Tab Bar UI + Layer Configuration** +- Created `rstack-tab-bar` web component with flat (tabs) and stack (SVG side-view) modes +- Drag-to-reorder tabs, add/close layers +- Extended Automerge CommunityDoc with layers, flows, activeLayerId, layerViewMode +- Core types: Layer, LayerFlow, FlowKind in `lib/layer-types.ts` + +**Phase 2 β€” Feed Definitions on Modules** +- Added FeedDefinition interface to shared/module.ts +- Added feeds and acceptsFeeds to 10 modules: funds, notes, vote, choices, wallet, data, work, network, trips, canvas +- Each module declares what feed kinds it exposes and accepts + +**Phase 3 β€” Folk Feed Shape** +- Built `folk-feed` canvas shape that fetches live data from other layers' module APIs +- Module-specific endpoint mapping and response normalization +- Auto-refresh on configurable interval +- Auto-flow detection when creating feed shapes + +**Phase 4 β€” Bidirectional Flows** +- Edit overlay with module-specific fields for write-back via PUT/PATCH +- Click-through navigation (double-click items) +- Drag-to-connect flows in stack view with kind/label/strength dialog +- Right-click to delete flows +- Full event wiring in shell.ts for all layer/flow CRUD operations + + +## Acceptance Criteria + +- [x] #1 Tab bar renders above canvas with flat/stack view toggle +- [x] #2 Layers persist in Automerge CommunityDoc for real-time sync +- [x] #3 10 modules declare feed definitions with FlowKind types +- [x] #4 folk-feed shape fetches live data from source module APIs +- [x] #5 Bidirectional write-back saves edits to source module +- [x] #6 Drag-to-connect in stack view creates typed flows +- [x] #7 Flow creation dialog with kind, label, and strength +- [x] #8 Auto-flow detection when creating feed shapes + + +## Final Summary + + +## Summary +Implemented a 4-phase layered tab system enabling inter-app data flows across the rSpace canvas. + +## Files Changed (18 files, +2539 lines) + +**New files:** +- `lib/layer-types.ts` (100 lines) β€” Core types: FlowKind, Layer, LayerFlow, FLOW_COLORS/FLOW_LABELS +- `lib/folk-feed.ts` (887 lines) β€” Canvas shape fetching live data from other layers with bidirectional write-back +- `shared/components/rstack-tab-bar.ts` (1080 lines) β€” Tab bar web component with flat/stack views, drag-to-connect flows + +**Modified files:** +- `lib/community-sync.ts` (+149 lines) β€” Extended CommunityDoc with layers/flows, 11 new CRDT methods +- `shared/module.ts` (+31 lines) β€” FeedDefinition interface, feeds/acceptsFeeds on RSpaceModule +- `server/shell.ts` (+50 lines) β€” Tab bar HTML, event wiring, CommunitySync integration +- `website/canvas.html` (+87 lines) β€” folk-feed registration, toolbar button, auto-flow detection +- `website/shell.ts` (+2 lines) β€” Component registration +- `website/public/shell.css` (+25 lines) β€” Tab row positioning +- `lib/index.ts` (+3 lines) β€” folk-feed barrel export +- 10 module mod.ts files β€” Feed definitions for funds, notes, vote, choices, wallet, data, work, network, trips, canvas + +## Commit +`cd440f1` feat: layered tab system with inter-layer flows and bidirectional feeds β€” merged to main + diff --git a/backlog/tasks/task-58 - Auto-route-users-to-personal-demo-space-landing-overlay-demo-content-for-all-rApps.md b/backlog/tasks/task-58 - Auto-route-users-to-personal-demo-space-landing-overlay-demo-content-for-all-rApps.md new file mode 100644 index 0000000..3163f79 --- /dev/null +++ b/backlog/tasks/task-58 - Auto-route-users-to-personal-demo-space-landing-overlay-demo-content-for-all-rApps.md @@ -0,0 +1,42 @@ +--- +id: TASK-58 +title: >- + Auto-route users to personal/demo space + landing overlay + demo content for + all rApps +status: Done +assignee: + - '@claude' +created_date: '2026-02-25 23:53' +labels: + - feature + - routing + - ux + - demo +dependencies: [] +priority: high +--- + +## Description + + +Anon users automatically land on the demo space when viewing any rApp (standalone or unified). Logged-in users auto-redirect to their personal space (auto-provisioned on first visit). Landing page renders as a quarter-screen popup overlay on the demo space. Demo space seeded with content for all 22 rApps. + + +## Acceptance Criteria + +- [ ] #1 Anon users on standalone domains (rpubs.online/) see demo space +- [ ] #2 Logged-in users auto-redirect from demo to personal space +- [ ] #3 POST /api/spaces/auto-provision creates personal space on first auth visit +- [ ] #4 Standalone domains support / path prefix (rpubs.online/jeff) +- [ ] #5 rspace.online/ redirects to /demo/canvas +- [ ] #6 Quarter-screen welcome overlay shows on first demo visit +- [ ] #7 Full landing page accessible at /about +- [ ] #8 Sign-in/register triggers auto-space-resolution redirect +- [ ] #9 Demo space seeded with shapes for all 22 rApps (55 shapes total) + + +## Final Summary + + +Implemented auto-routing: anonβ†’demo, authβ†’personal space. Added POST /api/spaces/auto-provision endpoint, modified standalone domain rewrite to support / paths, added welcome overlay to shell, wired auth flow to auto-redirect after sign-in. Seeded demo with 18 new shapes covering files, forum, books, pubs, swag, providers, work, cal, network, tube, inbox, data, choices, splat. Deployed to production, demo reset to 55 shapes. + diff --git a/modules/books/components/folk-book-reader.ts b/modules/books/components/folk-book-reader.ts index 923ad63..f8b2867 100644 --- a/modules/books/components/folk-book-reader.ts +++ b/modules/books/components/folk-book-reader.ts @@ -234,14 +234,13 @@ export class FolkBookReader extends HTMLElement { this.shadowRoot.innerHTML = ` ${this.getStyles()}
-
-
- ${this.escapeHtml(this._title)} - ${this._author ? `by ${this.escapeHtml(this._author)}` : ""} -
-
+
+ \u2190 Library + ${this.escapeHtml(this._title)} + ${this._author ? `by ${this.escapeHtml(this._author)}` : ""} + Page ${this._currentPage + 1} of ${this._numPages} -
+
@@ -438,32 +437,30 @@ export class FolkBookReader extends HTMLElement { gap: 0.75rem; } - .reader-header { + .rapp-nav { display: flex; - justify-content: space-between; align-items: center; + gap: 8px; width: 100%; max-width: 900px; + min-height: 36px; } - - .book-info { - display: flex; - flex-direction: column; - gap: 0.125rem; + .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; + text-decoration: none; transition: color 0.15s, border-color 0.15s; } - - .book-title { - font-weight: 600; - font-size: 1rem; - color: #f1f5f9; + .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; } - - .book-author { + .rapp-nav__subtitle { font-size: 0.8rem; color: #94a3b8; + margin-left: 4px; } - - .page-counter { + .rapp-nav__meta { font-size: 0.85rem; color: #94a3b8; white-space: nowrap; diff --git a/modules/books/components/folk-book-shelf.ts b/modules/books/components/folk-book-shelf.ts index 8051a56..fb8e21d 100644 --- a/modules/books/components/folk-book-shelf.ts +++ b/modules/books/components/folk-book-shelf.ts @@ -105,22 +105,11 @@ export class FolkBookShelf extends HTMLElement { margin: 0 auto; } - .shelf-header { - margin-bottom: 1.5rem; - } - - .shelf-header h2 { - margin: 0 0 0.5rem; - font-size: 1.5rem; - font-weight: 700; - color: #f1f5f9; - } - - .shelf-header p { - margin: 0 0 1rem; - color: #94a3b8; - font-size: 0.9rem; - } + .rapp-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; min-height: 36px; } + .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; } + .rapp-nav__btn:hover { background: #6366f1; } .controls { display: flex; @@ -376,14 +365,15 @@ export class FolkBookShelf extends HTMLElement { } -
-

πŸ“š Library

-

Community books β€” read, share, and contribute

+
+ Library +
+ +
-
${tags.length > 0 ? ` diff --git a/modules/photos/components/folk-photo-gallery.ts b/modules/photos/components/folk-photo-gallery.ts new file mode 100644 index 0000000..90f27b8 --- /dev/null +++ b/modules/photos/components/folk-photo-gallery.ts @@ -0,0 +1,421 @@ +/** + * β€” 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"; + this.loadGallery(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/photos/); + return match ? `/${match[1]}/photos` : ""; + } + + private getImmichUrl(): string { + return `https://${this.space}.rphotos.online`; + } + + 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"; + 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 ` +
+ 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) => ` +
+
+ ${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.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.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(" "); + + 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); diff --git a/modules/photos/components/photos.css b/modules/photos/components/photos.css new file mode 100644 index 0000000..2d19c19 --- /dev/null +++ b/modules/photos/components/photos.css @@ -0,0 +1,7 @@ +/* rPhotos module β€” shell-level overrides */ +folk-photo-gallery { + display: block; + padding: 1rem 1.5rem; + max-width: 1400px; + margin: 0 auto; +} diff --git a/modules/photos/mod.ts b/modules/photos/mod.ts new file mode 100644 index 0000000..4fd5f3b --- /dev/null +++ b/modules/photos/mod.ts @@ -0,0 +1,141 @@ +/** + * Photos module β€” community photo commons powered by Immich. + * + * Provides a gallery UI within the rSpace shell that connects to + * the Immich instance at {space}.rphotos.online. Proxies API requests + * for albums and thumbnails to avoid CORS issues. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +const IMMICH_BASE = process.env.RPHOTOS_IMMICH_URL || "http://localhost:2284"; +const IMMICH_API_KEY = process.env.RPHOTOS_API_KEY || ""; + +// ── Proxy: list shared albums ── +routes.get("/api/albums", async (c) => { + try { + const res = await fetch(`${IMMICH_BASE}/api/albums?shared=true`, { + headers: { "x-api-key": IMMICH_API_KEY }, + }); + if (!res.ok) return c.json({ albums: [] }); + const albums = await res.json(); + return c.json({ albums }); + } catch { + return c.json({ albums: [] }); + } +}); + +// ── Proxy: album detail with assets ── +routes.get("/api/albums/:id", async (c) => { + const id = c.req.param("id"); + try { + const res = await fetch(`${IMMICH_BASE}/api/albums/${id}`, { + headers: { "x-api-key": IMMICH_API_KEY }, + }); + if (!res.ok) return c.json({ error: "Album not found" }, 404); + return c.json(await res.json()); + } catch { + return c.json({ error: "Failed to load album" }, 500); + } +}); + +// ── Proxy: recent assets ── +routes.get("/api/assets", async (c) => { + const size = c.req.query("size") || "50"; + try { + const res = await fetch(`${IMMICH_BASE}/api/search/metadata`, { + method: "POST", + headers: { + "x-api-key": IMMICH_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + size: parseInt(size), + order: "desc", + type: "IMAGE", + }), + }); + if (!res.ok) return c.json({ assets: [] }); + const data = await res.json(); + return c.json({ assets: data.assets?.items || [] }); + } catch { + return c.json({ assets: [] }); + } +}); + +// ── Proxy: asset thumbnail ── +routes.get("/api/assets/:id/thumbnail", async (c) => { + const id = c.req.param("id"); + const size = c.req.query("size") || "thumbnail"; + try { + const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/thumbnail?size=${size}`, { + headers: { "x-api-key": IMMICH_API_KEY }, + }); + if (!res.ok) return c.body(null, 404); + const body = await res.arrayBuffer(); + return c.body(body, 200, { + "Content-Type": res.headers.get("Content-Type") || "image/jpeg", + "Cache-Control": "public, max-age=86400", + }); + } catch { + return c.body(null, 500); + } +}); + +// ── Proxy: full-size asset ── +routes.get("/api/assets/:id/original", async (c) => { + const id = c.req.param("id"); + try { + const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/original`, { + headers: { "x-api-key": IMMICH_API_KEY }, + }); + if (!res.ok) return c.body(null, 404); + const body = await res.arrayBuffer(); + return c.body(body, 200, { + "Content-Type": res.headers.get("Content-Type") || "image/jpeg", + "Cache-Control": "public, max-age=86400", + }); + } catch { + return c.body(null, 500); + } +}); + +// ── Page route ── +routes.get("/", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} β€” Photos | rSpace`, + moduleId: "photos", + spaceSlug, + modules: getModuleInfoList(), + theme: "dark", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const photosModule: RSpaceModule = { + id: "photos", + name: "rPhotos", + icon: "πŸ“Έ", + description: "Community photo commons", + routes, + standaloneDomain: "rphotos.online", + feeds: [ + { + id: "photos", + name: "Recent Photos", + kind: "data", + description: "Stream of recently uploaded photos", + emits: ["folk-image"], + filterable: true, + }, + ], + acceptsFeeds: ["data"], +}; diff --git a/modules/splat/components/folk-splat-viewer.ts b/modules/splat/components/folk-splat-viewer.ts index d06ed2f..2b9c971 100644 --- a/modules/splat/components/folk-splat-viewer.ts +++ b/modules/splat/components/folk-splat-viewer.ts @@ -124,8 +124,6 @@ export class FolkSplatViewer extends HTMLElement { this.innerHTML = `