feat: add rPhotos module + finish header standardization across all rApps
- Create modules/photos/ with Immich API proxy, gallery component, shared albums, lightbox viewer, and standard rapp-nav header - Register photosModule in server/index.ts and add vite build step - Fix remaining module headers: books (shelf + reader), splat, swag, tube - All 23 modules now use consistent rapp-nav pattern — no branding headers - Immich running at demo.rphotos.online, landing page at rphotos.online - Add backlog tasks 53-58 for recent feature work Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a5cd3c1322
commit
eba0aafc6e
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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)
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make all rApp headers identical (fix height mismatches, remove custom overrides) and add the mi AI assistant search bar to every page header.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
## 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
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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 /<space> 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)
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented auto-routing: anon→demo, auth→personal space. Added POST /api/spaces/auto-provision endpoint, modified standalone domain rewrite to support /<space> 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.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -234,14 +234,13 @@ export class FolkBookReader extends HTMLElement {
|
|||
this.shadowRoot.innerHTML = `
|
||||
${this.getStyles()}
|
||||
<div class="reader-container">
|
||||
<div class="reader-header">
|
||||
<div class="book-info">
|
||||
<span class="book-title">${this.escapeHtml(this._title)}</span>
|
||||
${this._author ? `<span class="book-author">by ${this.escapeHtml(this._author)}</span>` : ""}
|
||||
</div>
|
||||
<div class="page-counter">
|
||||
<div class="rapp-nav">
|
||||
<a class="rapp-nav__back" href="/${window.location.pathname.split('/')[1]}/books">\u2190 Library</a>
|
||||
<span class="rapp-nav__title">${this.escapeHtml(this._title)}</span>
|
||||
${this._author ? `<span class="rapp-nav__subtitle">by ${this.escapeHtml(this._author)}</span>` : ""}
|
||||
<span class="rapp-nav__meta">
|
||||
Page <span class="current-page">${this._currentPage + 1}</span> of ${this._numPages}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flipbook-wrapper">
|
||||
<button class="nav-btn nav-prev" title="Previous page">‹</button>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
</style>
|
||||
|
||||
<div class="shelf-header">
|
||||
<h2>📚 Library</h2>
|
||||
<p>Community books — read, share, and contribute</p>
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Library</span>
|
||||
<div class="rapp-nav__actions">
|
||||
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input class="search-input" type="text" placeholder="Search books..." />
|
||||
<button class="upload-btn">+ Add Book</button>
|
||||
</div>
|
||||
|
||||
${tags.length > 0 ? `
|
||||
|
|
|
|||
|
|
@ -0,0 +1,421 @@
|
|||
/**
|
||||
* <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";
|
||||
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 = `
|
||||
<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; }
|
||||
</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()}" target="_blank" rel="noopener">
|
||||
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()}" target="_blank" rel="noopener">
|
||||
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">
|
||||
${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}">
|
||||
<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">\u2190 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()}/albums/${album.id}" target="_blank" rel="noopener">
|
||||
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}">
|
||||
<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(" ");
|
||||
|
||||
return `
|
||||
<div class="lightbox" data-lightbox>
|
||||
<button class="lightbox-close" data-close-lightbox>\u2715</button>
|
||||
<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">
|
||||
<div class="lightbox-info">
|
||||
${asset.originalFileName}
|
||||
${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);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/* rPhotos module — shell-level overrides */
|
||||
folk-photo-gallery {
|
||||
display: block;
|
||||
padding: 1rem 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
|
@ -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: `<link rel="stylesheet" href="/modules/photos/photos.css">`,
|
||||
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
|
||||
scripts: `<script type="module" src="/modules/photos/folk-photo-gallery.js"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
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"],
|
||||
};
|
||||
|
|
@ -124,8 +124,6 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
|
||||
this.innerHTML = `
|
||||
<div class="splat-gallery">
|
||||
<h1>rSplat</h1>
|
||||
<p class="splat-gallery__subtitle">3D Gaussian Splat Gallery</p>
|
||||
${empty}
|
||||
<div class="splat-grid">${cards}</div>
|
||||
<div class="splat-upload" id="splat-drop">
|
||||
|
|
|
|||
|
|
@ -71,8 +71,7 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||
h2 { color: #f1f5f9; margin: 0 0 1.5rem; font-size: 1.5rem; }
|
||||
.products { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.products { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.product { padding: 1rem; border-radius: 12px; border: 2px solid #334155; background: #1e293b; cursor: pointer; text-align: center; transition: all 0.15s; }
|
||||
.product:hover { border-color: #475569; }
|
||||
.product.active { border-color: #6366f1; background: rgba(99,102,241,0.1); }
|
||||
|
|
@ -105,8 +104,6 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
input[type="file"] { display: none; }
|
||||
</style>
|
||||
|
||||
<h2>\u{1F3A8} Swag Designer</h2>
|
||||
|
||||
<div class="products">
|
||||
${products.map((p) => `
|
||||
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
<style>
|
||||
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: #e2e8f0; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
|
||||
.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; }
|
||||
.tab { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.875rem; }
|
||||
.tab.active { background: #ef4444; color: white; border-color: #ef4444; }
|
||||
.layout { display: grid; grid-template-columns: 300px 1fr; gap: 1.5rem; }
|
||||
|
|
@ -90,7 +91,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<div class="rapp-nav">
|
||||
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
|
||||
<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>
|
||||
</div>
|
||||
|
|
@ -144,7 +145,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
return `
|
||||
<div class="live-section">
|
||||
<div class="live-card">
|
||||
<h2 style="font-size:1.25rem;margin-bottom:0.5rem">Watch a Live Stream</h2>
|
||||
<h3 style="font-size:1rem;margin:0 0 0.5rem;color:#e2e8f0">Watch a Live Stream</h3>
|
||||
<p style="font-size:0.85rem;color:#94a3b8;margin-bottom:1.5rem">Enter the stream key to watch a live broadcast.</p>
|
||||
<input type="text" placeholder="Stream key (e.g. community-meeting)" data-input="streamkey" />
|
||||
<button class="btn-primary" data-action="watch">Watch Stream</button>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { tubeModule } from "../modules/tube/mod";
|
|||
import { inboxModule } from "../modules/inbox/mod";
|
||||
import { dataModule } from "../modules/data/mod";
|
||||
import { splatModule } from "../modules/splat/mod";
|
||||
import { photosModule } from "../modules/photos/mod";
|
||||
import { spaces } from "./spaces";
|
||||
import { renderShell } from "./shell";
|
||||
import { syncServer } from "./sync-instance";
|
||||
|
|
@ -90,6 +91,7 @@ registerModule(tubeModule);
|
|||
registerModule(inboxModule);
|
||||
registerModule(dataModule);
|
||||
registerModule(splatModule);
|
||||
registerModule(photosModule);
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
|
|
|||
|
|
@ -706,6 +706,33 @@ export default defineConfig({
|
|||
resolve(__dirname, "modules/splat/components/splat.css"),
|
||||
resolve(__dirname, "dist/modules/splat/splat.css"),
|
||||
);
|
||||
|
||||
// Build photos module component
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/photos/components"),
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/photos"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/photos/components/folk-photo-gallery.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "folk-photo-gallery.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "folk-photo-gallery.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Copy photos CSS
|
||||
mkdirSync(resolve(__dirname, "dist/modules/photos"), { recursive: true });
|
||||
copyFileSync(
|
||||
resolve(__dirname, "modules/photos/components/photos.css"),
|
||||
resolve(__dirname, "dist/modules/photos/photos.css"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue