From 5e0981fb71c5168f66911c45e589cb96f1247e01 Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Fri, 13 Feb 2026 01:10:17 +0100
Subject: [PATCH] grain photo gallery
---
.../CreateGrainGalleryCardModal.svelte | 62 ++++
.../PhotoGalleryCard/PhotoGalleryCard.svelte | 29 +-
.../cards/media/PhotoGalleryCard/helpers.ts | 78 +++++
src/lib/cards/media/PhotoGalleryCard/index.ts | 81 ++----
.../image-viewer/ImageViewerModal.svelte | 40 +++
.../image-viewer/ImageViewerProvider.svelte | 23 ++
.../image-viewer/imageViewer.svelte.ts | 14 +
src/lib/website/EditableWebsite.svelte | 275 +-----------------
src/lib/website/Website.svelte | 2 +
9 files changed, 260 insertions(+), 344 deletions(-)
create mode 100644 src/lib/cards/media/PhotoGalleryCard/CreateGrainGalleryCardModal.svelte
create mode 100644 src/lib/cards/media/PhotoGalleryCard/helpers.ts
create mode 100644 src/lib/components/image-viewer/ImageViewerModal.svelte
create mode 100644 src/lib/components/image-viewer/ImageViewerProvider.svelte
create mode 100644 src/lib/components/image-viewer/imageViewer.svelte.ts
diff --git a/src/lib/cards/media/PhotoGalleryCard/CreateGrainGalleryCardModal.svelte b/src/lib/cards/media/PhotoGalleryCard/CreateGrainGalleryCardModal.svelte
new file mode 100644
index 0000000..0a7c61c
--- /dev/null
+++ b/src/lib/cards/media/PhotoGalleryCard/CreateGrainGalleryCardModal.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
diff --git a/src/lib/cards/media/PhotoGalleryCard/PhotoGalleryCard.svelte b/src/lib/cards/media/PhotoGalleryCard/PhotoGalleryCard.svelte
index 43514ee..feddca6 100644
--- a/src/lib/cards/media/PhotoGalleryCard/PhotoGalleryCard.svelte
+++ b/src/lib/cards/media/PhotoGalleryCard/PhotoGalleryCard.svelte
@@ -1,16 +1,12 @@
+
+
+
+{#if open}
+
+
{
+ if (e.key === 'Escape') close();
+ }}
+ role="dialog"
+ aria-modal="true"
+ >
+
+
![]()
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ />
+
+{/if}
diff --git a/src/lib/components/image-viewer/ImageViewerProvider.svelte b/src/lib/components/image-viewer/ImageViewerProvider.svelte
new file mode 100644
index 0000000..2b62f3e
--- /dev/null
+++ b/src/lib/components/image-viewer/ImageViewerProvider.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/lib/components/image-viewer/imageViewer.svelte.ts b/src/lib/components/image-viewer/imageViewer.svelte.ts
new file mode 100644
index 0000000..ecb4edd
--- /dev/null
+++ b/src/lib/components/image-viewer/imageViewer.svelte.ts
@@ -0,0 +1,14 @@
+// Global state for fullscreen image viewer
+let openFn: ((src: string) => void) | null = null;
+
+export function registerImageViewer(fn: (src: string) => void) {
+ openFn = fn;
+}
+
+export function unregisterImageViewer() {
+ openFn = null;
+}
+
+export function openImageViewer(src: string) {
+ openFn?.(src);
+}
diff --git a/src/lib/website/EditableWebsite.svelte b/src/lib/website/EditableWebsite.svelte
index 7d3af0c..a811cdd 100644
--- a/src/lib/website/EditableWebsite.svelte
+++ b/src/lib/website/EditableWebsite.svelte
@@ -34,6 +34,7 @@
import { launchConfetti } from '@foxui/visual';
import Controls from './Controls.svelte';
import CardCommand from '$lib/components/card-command/CardCommand.svelte';
+ import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte';
import { SvelteMap } from 'svelte/reactivity';
import {
fixCollisions,
@@ -244,279 +245,6 @@
}
}
- function addAllCardTypes() {
- const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games'];
- const grouped = new SvelteMap();
-
- for (const def of AllCardDefinitions) {
- if (!def.name) continue;
- const group = def.groups?.[0] ?? 'Other';
- if (!grouped.has(group)) grouped.set(group, []);
- grouped.get(group)!.push(def);
- }
-
- // Sort groups by predefined order, unknowns at end
- const sortedGroups = [...grouped.keys()].sort((a, b) => {
- const ai = groupOrder.indexOf(a);
- const bi = groupOrder.indexOf(b);
- return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
- });
-
- // Sample data for cards that would otherwise render empty
- const sampleData: Record> = {
- text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' },
- link: {
- href: 'https://bsky.app',
- title: 'Bluesky',
- domain: 'bsky.app',
- description: 'Social networking that gives you choice',
- hasFetched: true
- },
- image: {
- image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600',
- alt: 'Mountain landscape'
- },
- button: { text: 'Visit Bluesky', href: 'https://bsky.app' },
- bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' },
- blueskyPost: {
- uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y',
- href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y'
- },
- blueskyProfile: {
- handle: 'bsky.app',
- displayName: 'Bluesky',
- avatar:
- 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg'
- },
- blueskyMedia: {},
- latestPost: {},
- youtubeVideo: {
- youtubeId: 'dQw4w9WgXcQ',
- poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
- href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
- showInline: true
- },
- 'spotify-list-embed': {
- spotifyType: 'album',
- spotifyId: '4aawyAB9vmqN3uQ7FjRGTy',
- href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'
- },
- latestLivestream: {},
- livestreamEmbed: {
- href: 'https://stream.place/',
- embed: 'https://stream.place/embed/'
- },
- mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' },
- gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' },
- event: {
- uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q'
- },
- guestbook: { label: 'Guestbook' },
- githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' },
- photoGallery: {
- galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w'
- },
- atprotocollections: {},
- publicationList: {},
- recentPopfeedReviews: {},
- recentTealFMPlays: {},
- statusphere: { emoji: '✨' },
- vcard: {},
- 'fluid-text': { text: 'Hello World' },
- draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true },
- clock: {},
- countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() },
- timer: {},
- 'dino-game': {},
- tetris: {},
- updatedBlentos: {}
- };
-
- // Labels for cards that support canHaveLabel
- const sampleLabels: Record = {
- image: 'Mountain Landscape',
- mapLocation: 'Eiffel Tower',
- gif: 'Cat Typing',
- bigsocial: 'Bluesky',
- guestbook: 'Guestbook',
- statusphere: 'My Status',
- recentPopfeedReviews: 'My Reviews',
- recentTealFMPlays: 'Recently Played',
- clock: 'Local Time',
- countdown: 'Launch Day',
- timer: 'Timer',
- 'dino-game': 'Dino Game',
- tetris: 'Tetris',
- blueskyMedia: 'Bluesky Media'
- };
-
- const newItems: Item[] = [];
- let cursorY = 0;
- let mobileCursorY = 0;
-
- for (const group of sortedGroups) {
- const defs = grouped.get(group)!;
-
- // Add a section heading for the group
- const heading = createEmptyCard(data.page);
- heading.cardType = 'section';
- heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 };
- heading.w = COLUMNS;
- heading.h = 1;
- heading.x = 0;
- heading.y = cursorY;
- heading.mobileW = COLUMNS;
- heading.mobileH = 2;
- heading.mobileX = 0;
- heading.mobileY = mobileCursorY;
- newItems.push(heading);
- cursorY += 1;
- mobileCursorY += 2;
-
- // Place cards in rows
- let rowX = 0;
- let rowMaxH = 0;
- let mobileRowX = 0;
- let mobileRowMaxH = 0;
-
- for (const def of defs) {
- if (def.type === 'section' || def.type === 'embed') continue;
-
- const item = createEmptyCard(data.page);
- item.cardType = def.type;
- item.cardData = {};
- def.createNew?.(item);
-
- // Merge in sample data (without overwriting createNew defaults)
- const extra = sampleData[def.type];
- if (extra) {
- item.cardData = { ...item.cardData, ...extra };
- }
-
- // Set item-level color for cards that need it
- if (def.type === 'button') {
- item.color = 'transparent';
- }
-
- // Add label if card supports it
- const label = sampleLabels[def.type];
- if (label && def.canHaveLabel) {
- item.cardData.label = label;
- }
-
- // Desktop layout
- if (rowX + item.w > COLUMNS) {
- cursorY += rowMaxH;
- rowX = 0;
- rowMaxH = 0;
- }
- item.x = rowX;
- item.y = cursorY;
- rowX += item.w;
- rowMaxH = Math.max(rowMaxH, item.h);
-
- // Mobile layout
- if (mobileRowX + item.mobileW > COLUMNS) {
- mobileCursorY += mobileRowMaxH;
- mobileRowX = 0;
- mobileRowMaxH = 0;
- }
- item.mobileX = mobileRowX;
- item.mobileY = mobileCursorY;
- mobileRowX += item.mobileW;
- mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH);
-
- newItems.push(item);
- }
-
- // Move cursor past last row
- cursorY += rowMaxH;
- mobileCursorY += mobileRowMaxH;
- }
-
- items = newItems;
- onLayoutChanged();
- }
-
- let copyInput = $state('');
- let isCopying = $state(false);
-
- async function copyPageFrom() {
- const input = copyInput.trim();
- if (!input) return;
-
- isCopying = true;
- try {
- // Parse "handle" or "handle/page"
- const parts = input.split('/');
- const handle = parts[0];
- const pageName = parts[1] || 'self';
-
- const did = await resolveHandle({ handle: handle as `${string}.${string}` });
- if (!did) throw new Error('Could not resolve handle');
-
- const records = await listRecords({ did, collection: 'app.blento.card' });
- const targetPage = 'blento.' + pageName;
-
- const copiedCards: Item[] = records
- .map((r) => ({ ...r.value }) as Item)
- .filter((card) => {
- // v0/v1 cards without page field belong to blento.self
- if (!card.page) return targetPage === 'blento.self';
- return card.page === targetPage;
- })
- .map((card) => {
- // Apply v0→v1 migration (coords were halved in old format)
- if (!card.version) {
- card.x *= 2;
- card.y *= 2;
- card.h *= 2;
- card.w *= 2;
- card.mobileX *= 2;
- card.mobileY *= 2;
- card.mobileH *= 2;
- card.mobileW *= 2;
- card.version = 1;
- }
-
- // Convert blob refs to CDN URLs using source DID
- if (card.cardData) {
- for (const key of Object.keys(card.cardData)) {
- const val = card.cardData[key];
- if (val && typeof val === 'object' && val.$type === 'blob') {
- const url = getCDNImageBlobUrl({ did, blob: val });
- if (url) card.cardData[key] = url;
- }
- }
- }
-
- // Regenerate ID and assign to current page
- card.id = TID.now();
- card.page = data.page;
- return card;
- });
-
- if (copiedCards.length === 0) {
- toast.error('No cards found on that page');
- return;
- }
-
- fixAllCollisions(copiedCards, false);
- fixAllCollisions(copiedCards, true);
- compactItems(copiedCards, false);
- compactItems(copiedCards, true);
-
- items = copiedCards;
- onLayoutChanged();
- toast.success(`Copied ${copiedCards.length} cards from ${handle}`);
- } catch (e) {
- console.error('Failed to copy page:', e);
- toast.error('Failed to copy page');
- } finally {
- isCopying = false;
- }
- }
-
let linkValue = $state('');
function addLink(url: string, specificCardDef?: CardDefinition) {
@@ -769,6 +497,7 @@
+
{
diff --git a/src/lib/website/Website.svelte b/src/lib/website/Website.svelte
index 86567d3..365c948 100644
--- a/src/lib/website/Website.svelte
+++ b/src/lib/website/Website.svelte
@@ -18,6 +18,7 @@
import Head from './Head.svelte';
import type { Did, Handle } from '@atcute/lexicons';
import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte';
+ import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte';
import EmptyState from './EmptyState.svelte';
import FloatingEditButton from './FloatingEditButton.svelte';
import { user } from '$lib/atproto';
@@ -67,6 +68,7 @@
+
{#if !getHideProfileSection(data)}
--
2.51.2