diff --git a/docs/todo/external-section-sources.md b/docs/todo/external-section-sources.md new file mode 100644 index 0000000..78c6959 --- /dev/null +++ b/docs/todo/external-section-sources.md @@ -0,0 +1,105 @@ +# External data sources for sections + +Allow sections to be auto-filled from external data (e.g. Grain gallery photos, standard.site blog posts) instead of manually curated cards. + +## Goals + +- Sections can pull items from external sources (ATProto collections, web APIs, etc.) without the user manually creating each card. +- Extension path mirrors the existing card `loadData` pattern so there's one consistent idea in the codebase. +- A section can always fall back to manual content (same UX as today). +- Multiple sections on a page can use different sources; a single page can mix sourced and manual sections. + +## Proposed API + +### SectionDefinition additions + +```ts +type SectionDefinition = { + // existing... + + // Optional external data fetch. Result stored in WebsiteData.additionalData[section.id]. + loadData?: (section: SectionRecord, ctx: { did; handle; cache? }) => Promise; + loadDataServer?: ( + section: SectionRecord, + ctx: { did; handle; cache?; env?; platform? } + ) => Promise; +}; +``` + +Mirrors the card-level `loadData`/`loadDataServer`. Reuses the same cache service + KV infrastructure. + +### sectionData shape + +Each section that supports sourcing stores its source config in `sectionData`: + +```ts +// Manual (default) +sectionData: { source: { provider: 'manual' }, ...otherStuff } + +// Sourced from Grain +sectionData: { + source: { provider: 'grain-gallery', galleryUri: 'at://did:.../xyz.grain.photo.gallery/abc' } +} + +// Sourced from standard.site blog +sectionData: { + source: { provider: 'standard-site-blog', did: 'did:...', limit: 10 } +} +``` + +### Source registration + +Sources are pluggable modules that can be used by one or more section types: + +```ts +// src/lib/sections/sources/types.ts +type SectionSource = { + id: string; // e.g. 'grain-gallery' + name: string; // user-facing name + supportedSections: string[]; // e.g. ['gallery'] + configComponent: Component<{ + config: any; + onchange: (next: any) => void; + }>; + loadData?: (config: any, ctx: any) => Promise; + loadDataServer?: (config: any, ctx: any) => Promise; +}; +``` + +Registered in a central `AllSectionSources` array like cards/sections are. Section types query the list filtered by `supportedSections`. + +Example: `src/lib/sections/sources/grain-gallery.ts` registers a source with `supportedSections: ['gallery']` and a `loadDataServer` that fetches photos via the Grain lexicon. + +### Rendering flow + +The section's `contentComponent` receives: + +- `items` (manual cards assigned to the section, as today) +- `externalData` (from `additionalData[section.id]`) + +If `sectionData.source.provider !== 'manual'`, the section renders from `externalData`; otherwise it renders `items`. Optionally, a section could combine both (e.g. pinned manual items + auto-pulled items). + +External items are "synthetic": they look like `Item`s at the render layer (enough for `BaseCard` / `Card` to render them) but they have no PDS record and no `sectionId`. They reuse existing card types — a Grain photo renders as a synthetic `image` card, a blog post as a synthetic `link` card. No new card components needed. + +### Editing UX + +- Section settings popover/drawer gets a **Source** dropdown listing compatible sources (derived from `AllSectionSources` filtered to the current section type) +- When the source is non-manual, the inline card-editing UI is replaced with the source's `configComponent` (e.g. "Paste Grain gallery URI") +- Switching source back to "Manual" restores the manual editing UX (and the existing cards resurface) +- The source's config is persisted in `sectionData.source` + +## Implementation order + +1. Add `loadData` / `loadDataServer` to `SectionDefinition` and wire into `load.ts` (server-side fan-out like cards already do). +2. Add the sources registry (`src/lib/sections/sources/index.ts`). +3. Teach `contentComponent` / `editingContentComponent` to branch on `sectionData.source.provider`. +4. Build one real source end-to-end: **Grain gallery → Gallery section** (infrastructure already exists in `PhotoGalleryCard`). +5. Follow with: **standard.site blog → Row section** (requires a blog-post card first, or a synthetic link card with richer metadata). + +## Open questions + +- Should sources be able to return card-type-specific `cardData`, or should they always return a normalised shape? Probably card-type-specific for flexibility. +- Pagination: do sources need to know about "load more" or infinite scroll? For now assume batch-fetch (limit in config). +- Caching: per-section cache key by `section.id + section.updatedAt`? Or by source config hash so multiple sections with the same source share cache? Probably the latter. +- Mixing sourced + manual: start with mutually exclusive (source OR manual), add mixing later if needed. +- Mobile UX for the Source dropdown and config UI: same treatment as the card editing popovers. diff --git a/lexicons/app/blento/card.json b/lexicons/app/blento/card.json index 3a11945..5917df6 100644 --- a/lexicons/app/blento/card.json +++ b/lexicons/app/blento/card.json @@ -23,6 +23,7 @@ "color": { "type": "string" }, "page": { "type": "string" }, "sectionId": { "type": "string" }, + "rotation": { "type": "integer" }, "updatedAt": { "type": "string", "format": "datetime" }, "version": { "type": "integer" } } diff --git a/src/lib/cards/types.ts b/src/lib/cards/types.ts index dc21d63..e7d5ffc 100644 --- a/src/lib/cards/types.ts +++ b/src/lib/cards/types.ts @@ -80,6 +80,9 @@ export type CardDefinition = { canResize?: boolean; + // allow rotation for this card type (default true — section can also disable) + allowRotate?: boolean; + // if true, content can render outside the card's bounds (no overflow:hidden on the inner wrapper) noOverflow?: boolean; diff --git a/src/lib/sections/GallerySection/EditingGallerySection.svelte b/src/lib/sections/GallerySection/EditingGallerySection.svelte new file mode 100644 index 0000000..740b16e --- /dev/null +++ b/src/lib/sections/GallerySection/EditingGallerySection.svelte @@ -0,0 +1,153 @@ + + + + +
+ + + {#if sectionItems.length > 0} + + + {/if} + +
+ +
+
+ + diff --git a/src/lib/sections/GallerySection/GallerySection.svelte b/src/lib/sections/GallerySection/GallerySection.svelte new file mode 100644 index 0000000..de77c84 --- /dev/null +++ b/src/lib/sections/GallerySection/GallerySection.svelte @@ -0,0 +1,60 @@ + + +
+ + +
+ + diff --git a/src/lib/sections/GallerySection/index.ts b/src/lib/sections/GallerySection/index.ts new file mode 100644 index 0000000..0e53956 --- /dev/null +++ b/src/lib/sections/GallerySection/index.ts @@ -0,0 +1,34 @@ +import type { SectionDefinition } from '../types'; +import EditingGallerySection from './EditingGallerySection.svelte'; +import GallerySection from './GallerySection.svelte'; + +export function defaultGallerySectionData(): Record { + return { + columns: 3, + gap: 2 + }; +} + +export const GallerySectionDefinition: SectionDefinition = { + type: 'gallery', + contentComponent: GallerySection, + editingContentComponent: EditingGallerySection, + defaultSectionData: defaultGallerySectionData, + cardFilter: (def) => def.type === 'image' || def.type === 'gif', + addItem: (item, allItems) => { + item.w = 2; + item.h = 2; + item.mobileW = 4; + item.mobileH = 4; + const sectionItems = allItems.filter((i) => i.sectionId === item.sectionId); + item.x = sectionItems.length; + item.y = 0; + item.mobileX = 0; + item.mobileY = sectionItems.length; + return [...allItems, item]; + }, + deleteItem: (itemId, allItems) => allItems.filter((i) => i.id !== itemId), + resizeItem: () => {}, + name: 'Gallery', + icon: `` +}; diff --git a/src/lib/sections/GridSection/EditingGridSection.svelte b/src/lib/sections/GridSection/EditingGridSection.svelte index 7076126..d38ad67 100644 --- a/src/lib/sections/GridSection/EditingGridSection.svelte +++ b/src/lib/sections/GridSection/EditingGridSection.svelte @@ -4,8 +4,8 @@ import { EditableGrid, fixCollisions, compactItems, setPositionOfNewItem } from '$lib/layout'; import GridBaseEditingCard from '$lib/cards/_base/BaseCard/GridBaseEditingCard.svelte'; import EditingCard from '$lib/cards/_base/Card/EditingCard.svelte'; - import { SectionDefinitionsByType } from '$lib/sections'; - import { SECTIONS_EDITING_ENABLED } from '$lib/sections/feature-flag'; + import SectionChrome from '../SectionChrome.svelte'; + import { GRID_SECTION_ICON, GRID_SECTION_NAME } from './shared'; import { positionItemAtGridPos } from './add-item'; let { @@ -28,7 +28,6 @@ let sectionItems = $derived(items.filter((i) => i.sectionId === section.id)); let hovered = $state(false); - const def = $derived(SectionDefinitionsByType[section.sectionType]); $effect(() => { onrefchange(gridRef); @@ -85,24 +84,12 @@ {ondeselect} onfiledrop={handleFileDrop} > - {#if SECTIONS_EDITING_ENABLED && (hovered || isActive)} -
-
- {#if def?.icon} - {@html def.icon} - {/if} - {section.name || def?.name || section.sectionType} -
-
- {/if} + {#if sectionItems.length === 0}
i.sectionId === sectionId); @@ -32,6 +35,6 @@ export const GridSectionDefinition: SectionDefinition = { const sectionItems = getSectionItems(allItems, item.sectionId!); fixCollisions(sectionItems, item, isMobile); }, - name: 'Grid', - icon: `` + name: GRID_SECTION_NAME, + icon: GRID_SECTION_ICON }; diff --git a/src/lib/sections/GridSection/shared.ts b/src/lib/sections/GridSection/shared.ts new file mode 100644 index 0000000..bfc4fbf --- /dev/null +++ b/src/lib/sections/GridSection/shared.ts @@ -0,0 +1,2 @@ +export const GRID_SECTION_NAME = 'Grid'; +export const GRID_SECTION_ICON = ``; diff --git a/src/lib/sections/HeroSection/Decoration.svelte b/src/lib/sections/HeroSection/Decoration.svelte index fe03153..8b2741a 100644 --- a/src/lib/sections/HeroSection/Decoration.svelte +++ b/src/lib/sections/HeroSection/Decoration.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/sections/HeroSection/EditingHeroSection.svelte b/src/lib/sections/HeroSection/EditingHeroSection.svelte index ad7cb85..f451cb1 100644 --- a/src/lib/sections/HeroSection/EditingHeroSection.svelte +++ b/src/lib/sections/HeroSection/EditingHeroSection.svelte @@ -15,13 +15,15 @@ import type { EditingSectionContentProps } from '../types'; import { DEFAULT_DECORATION_SLOTS, - canFitInSlot, getSlotAssignments, getSlotItem, heroAlignClasses, heroVerticalAlignClasses - } from '.'; + } from './shared'; import Decoration from './Decoration.svelte'; + import SectionChrome from '../SectionChrome.svelte'; + + const heroIcon = ``; let { section, @@ -142,34 +144,9 @@ bind:this={containerRef} class="@container/grid pointer-events-auto relative col-span-3 flex min-h-[calc(100dvh-4rem)] flex-col overflow-visible px-2 py-10 lg:px-8" > - {#if hovered || isActive} -
-
- - Hero -
-
+ + {#if hovered || isActive}
{#snippet child({ props })} diff --git a/src/lib/sections/HeroSection/HeroSection.svelte b/src/lib/sections/HeroSection/HeroSection.svelte index 1468cd6..6fd7a6e 100644 --- a/src/lib/sections/HeroSection/HeroSection.svelte +++ b/src/lib/sections/HeroSection/HeroSection.svelte @@ -11,7 +11,7 @@ getSlotItem, heroAlignClasses, heroVerticalAlignClasses - } from '.'; + } from './shared'; import Decoration from './Decoration.svelte'; let { section, items }: SectionContentProps = $props(); diff --git a/src/lib/sections/HeroSection/index.ts b/src/lib/sections/HeroSection/index.ts index 197e06d..bf40428 100644 --- a/src/lib/sections/HeroSection/index.ts +++ b/src/lib/sections/HeroSection/index.ts @@ -1,69 +1,10 @@ -import type { Item, SectionRecord } from '$lib/types'; +import type { Item } from '$lib/types'; import type { SectionDefinition } from '../types'; import EditingHeroSection from './EditingHeroSection.svelte'; import HeroSection from './HeroSection.svelte'; +import { DEFAULT_DECORATION_SLOTS, canFitInSlot, defaultHeroSectionData } from './shared'; -export type DecorationSlot = { - id: string; - side: 'left' | 'right'; - top: number; - rotation: number; -}; - -export const DEFAULT_DECORATION_SLOTS: DecorationSlot[] = [ - { id: 'slot-l-0', side: 'left', top: 15, rotation: -10 }, - { id: 'slot-l-1', side: 'left', top: 50, rotation: -4 }, - { id: 'slot-l-2', side: 'left', top: 82, rotation: -12 }, - { id: 'slot-r-0', side: 'right', top: 18, rotation: 8 }, - { id: 'slot-r-1', side: 'right', top: 52, rotation: -12 }, - { id: 'slot-r-2', side: 'right', top: 80, rotation: 6 } -]; - -export function getSlotAssignments(sectionData: Record): Record { - return (sectionData.slotAssignments as Record) ?? {}; -} - -export function getSlotItem( - slot: DecorationSlot, - assignments: Record, - items: Item[] -): Item | undefined { - const itemId = assignments[slot.id]; - if (!itemId) return undefined; - return items.find((i) => i.id === itemId); -} - -export function canFitInSlot(cardDef: { minW?: number; minH?: number }): boolean { - return (cardDef.minW ?? 2) <= 2 && (cardDef.minH ?? 2) <= 2; -} - -export function defaultHeroSectionData(): Record { - return { - title: 'My cool website', - subtitle: 'A little something about me, what I do, and why you should stick around.', - badge: 'Welcome', - buttonText: 'Say hi', - buttonHref: '', - showBadge: true, - showSubtitle: true, - showButton: true, - textAlign: 'center', - verticalAlign: 'center', - slotAssignments: {} - }; -} - -export const heroAlignClasses: Record = { - left: 'text-left items-start', - center: 'text-center items-center', - right: 'text-right items-end' -}; - -export const heroVerticalAlignClasses: Record = { - top: 'justify-start', - center: 'justify-center', - bottom: 'justify-end' -}; +export * from './shared'; export const HeroSectionDefinition: SectionDefinition = { type: 'hero', @@ -71,19 +12,23 @@ export const HeroSectionDefinition: SectionDefinition = { editingContentComponent: EditingHeroSection, defaultSectionData: defaultHeroSectionData, cardFilter: canFitInSlot, + allowRotate: true, addItem: (item: Item, allItems: Item[], options) => { item.w = 2; item.h = 2; item.mobileW = 2; item.mobileH = 2; - if (options.extraData?.slotId) { - item.cardData._slotId = options.extraData.slotId; + const slotId = options.extraData?.slotId; + if (slotId) { + item.cardData._slotId = slotId; + const slot = DEFAULT_DECORATION_SLOTS.find((s) => s.id === slotId); + if (slot && item.rotation === undefined) { + item.rotation = slot.rotation; + } } return [...allItems, item]; }, - deleteItem: (itemId: string, allItems: Item[]) => { - return allItems.filter((i) => i.id !== itemId); - }, + deleteItem: (itemId: string, allItems: Item[]) => allItems.filter((i) => i.id !== itemId), resizeItem: () => {}, name: 'Hero', icon: `` diff --git a/src/lib/sections/HeroSection/shared.ts b/src/lib/sections/HeroSection/shared.ts new file mode 100644 index 0000000..5c2c964 --- /dev/null +++ b/src/lib/sections/HeroSection/shared.ts @@ -0,0 +1,63 @@ +import type { Item } from '$lib/types'; + +export type DecorationSlot = { + id: string; + side: 'left' | 'right'; + top: number; + rotation: number; +}; + +export const DEFAULT_DECORATION_SLOTS: DecorationSlot[] = [ + { id: 'slot-l-0', side: 'left', top: 15, rotation: -10 }, + { id: 'slot-l-1', side: 'left', top: 50, rotation: -4 }, + { id: 'slot-l-2', side: 'left', top: 82, rotation: -12 }, + { id: 'slot-r-0', side: 'right', top: 18, rotation: 8 }, + { id: 'slot-r-1', side: 'right', top: 52, rotation: -12 }, + { id: 'slot-r-2', side: 'right', top: 80, rotation: 6 } +]; + +export function getSlotAssignments(sectionData: Record): Record { + return (sectionData.slotAssignments as Record) ?? {}; +} + +export function getSlotItem( + slot: DecorationSlot, + assignments: Record, + items: Item[] +): Item | undefined { + const itemId = assignments[slot.id]; + if (!itemId) return undefined; + return items.find((i) => i.id === itemId); +} + +export function canFitInSlot(cardDef: { minW?: number; minH?: number }): boolean { + return (cardDef.minW ?? 2) <= 2 && (cardDef.minH ?? 2) <= 2; +} + +export function defaultHeroSectionData(): Record { + return { + title: 'My cool website', + subtitle: 'A little something about me, what I do, and why you should stick around.', + badge: 'Welcome', + buttonText: 'Say hi', + buttonHref: '', + showBadge: true, + showSubtitle: true, + showButton: true, + textAlign: 'center', + verticalAlign: 'center', + slotAssignments: {} + }; +} + +export const heroAlignClasses: Record = { + left: 'text-left items-start', + center: 'text-center items-center', + right: 'text-right items-end' +}; + +export const heroVerticalAlignClasses: Record = { + top: 'justify-start', + center: 'justify-center', + bottom: 'justify-end' +}; diff --git a/src/lib/sections/RowSection/EditingRowSection.svelte b/src/lib/sections/RowSection/EditingRowSection.svelte new file mode 100644 index 0000000..90b98d3 --- /dev/null +++ b/src/lib/sections/RowSection/EditingRowSection.svelte @@ -0,0 +1,100 @@ + + +
+ + +
+
+ {#each sectionItems as item (item.id)} + {@const idx = items.indexOf(item)} +
+ deleteItem(item.id)} + showGridControls={false} + > + + +
+ {/each} + + +
+
+
diff --git a/src/lib/sections/RowSection/RowSection.svelte b/src/lib/sections/RowSection/RowSection.svelte new file mode 100644 index 0000000..841d6c3 --- /dev/null +++ b/src/lib/sections/RowSection/RowSection.svelte @@ -0,0 +1,28 @@ + + +
+
+
+ {#each sectionItems as item (item.id)} +
+ + + +
+ {/each} +
+
+
diff --git a/src/lib/sections/RowSection/index.ts b/src/lib/sections/RowSection/index.ts new file mode 100644 index 0000000..d567b77 --- /dev/null +++ b/src/lib/sections/RowSection/index.ts @@ -0,0 +1,34 @@ +import type { SectionDefinition } from '../types'; +import EditingRowSection from './EditingRowSection.svelte'; +import RowSection from './RowSection.svelte'; + +export function defaultRowSectionData(): Record { + return { + scrollMode: 'scroll' // 'scroll' | 'fit' + }; +} + +export const RowSectionDefinition: SectionDefinition = { + type: 'row', + contentComponent: RowSection, + editingContentComponent: EditingRowSection, + defaultSectionData: defaultRowSectionData, + cardFilter: (def) => (def.minW ?? 2) <= 2 && (def.minH ?? 2) <= 2, + allowRotate: true, + addItem: (item, allItems) => { + item.w = 2; + item.h = 2; + item.mobileW = 4; + item.mobileH = 4; + const sectionItems = allItems.filter((i) => i.sectionId === item.sectionId); + item.x = sectionItems.length; + item.y = 0; + item.mobileX = 0; + item.mobileY = sectionItems.length; + return [...allItems, item]; + }, + deleteItem: (itemId, allItems) => allItems.filter((i) => i.id !== itemId), + resizeItem: () => {}, + name: 'Row', + icon: `` +}; diff --git a/src/lib/sections/SectionChrome.svelte b/src/lib/sections/SectionChrome.svelte new file mode 100644 index 0000000..cee6d3c --- /dev/null +++ b/src/lib/sections/SectionChrome.svelte @@ -0,0 +1,34 @@ + + +{#if SECTIONS_EDITING_ENABLED && (hovered || isActive)} +
+
+ {#if icon} + {@html icon} + {/if} + {name} +
+
+{/if} diff --git a/src/lib/sections/TextSection/EditingTextSection.svelte b/src/lib/sections/TextSection/EditingTextSection.svelte new file mode 100644 index 0000000..a9d567f --- /dev/null +++ b/src/lib/sections/TextSection/EditingTextSection.svelte @@ -0,0 +1,64 @@ + + +
+ + +
+ update('text', text)} + /> +
+
diff --git a/src/lib/sections/TextSection/TextSection.svelte b/src/lib/sections/TextSection/TextSection.svelte new file mode 100644 index 0000000..5a282f7 --- /dev/null +++ b/src/lib/sections/TextSection/TextSection.svelte @@ -0,0 +1,27 @@ + + +
+
+ {@html sanitize(marked.parse(d.text ?? '', { renderer }) as string, { ADD_ATTR: ['target'] })} +
+
diff --git a/src/lib/sections/TextSection/index.ts b/src/lib/sections/TextSection/index.ts new file mode 100644 index 0000000..b2f079e --- /dev/null +++ b/src/lib/sections/TextSection/index.ts @@ -0,0 +1,18 @@ +import type { SectionDefinition } from '../types'; +import EditingTextSection from './EditingTextSection.svelte'; +import TextSection from './TextSection.svelte'; +import { defaultTextSectionData } from './shared'; + +export * from './shared'; + +export const TextSectionDefinition: SectionDefinition = { + type: 'text', + contentComponent: TextSection, + editingContentComponent: EditingTextSection, + defaultSectionData: defaultTextSectionData, + addItem: (_item, allItems) => allItems, + deleteItem: (_itemId, allItems) => allItems, + resizeItem: () => {}, + name: 'Text', + icon: `` +}; diff --git a/src/lib/sections/TextSection/shared.ts b/src/lib/sections/TextSection/shared.ts new file mode 100644 index 0000000..59578b4 --- /dev/null +++ b/src/lib/sections/TextSection/shared.ts @@ -0,0 +1,15 @@ +export const textAlignClasses: Record = { + left: 'text-left', + center: 'text-center', + right: 'text-right' +}; + +export const textSizeClasses = ['prose-sm', 'prose-base', 'prose-lg', 'prose-xl']; + +export function defaultTextSectionData(): Record { + return { + text: '## A heading\n\nWrite some **markdown** here. Links like [this one](https://blento.app) work too.', + textAlign: 'left', + textSize: 1 + }; +} diff --git a/src/lib/sections/feature-flag.ts b/src/lib/sections/feature-flag.ts index 7227cd6..1ca99f4 100644 --- a/src/lib/sections/feature-flag.ts +++ b/src/lib/sections/feature-flag.ts @@ -1,3 +1,4 @@ +import { dev } from '$app/environment'; import { env } from '$env/dynamic/public'; /** diff --git a/src/lib/sections/index.ts b/src/lib/sections/index.ts index e2641d4..69befd5 100644 --- a/src/lib/sections/index.ts +++ b/src/lib/sections/index.ts @@ -1,8 +1,17 @@ import type { SectionDefinition } from './types'; import { GridSectionDefinition } from './GridSection'; import { HeroSectionDefinition } from './HeroSection'; +import { TextSectionDefinition } from './TextSection'; +import { RowSectionDefinition } from './RowSection'; +import { GallerySectionDefinition } from './GallerySection'; -export const AllSectionDefinitions = [GridSectionDefinition, HeroSectionDefinition] as const; +export const AllSectionDefinitions = [ + GridSectionDefinition, + HeroSectionDefinition, + TextSectionDefinition, + RowSectionDefinition, + GallerySectionDefinition +] as const; export const SectionDefinitionsByType = AllSectionDefinitions.reduce( (acc, def) => { diff --git a/src/lib/sections/types.ts b/src/lib/sections/types.ts index 848b97f..7a9cfdf 100644 --- a/src/lib/sections/types.ts +++ b/src/lib/sections/types.ts @@ -32,6 +32,7 @@ export type SectionDefinition = { editingContentComponent: Component; defaultSectionData?: () => Record; cardFilter?: (cardDef: CardDefinition) => boolean; + allowRotate?: boolean; addItem: (item: Item, allItems: Item[], options: AddItemOptions) => Item[]; deleteItem: (itemId: string, allItems: Item[], sectionId: string) => Item[]; resizeItem: (item: Item, allItems: Item[], w: number, h: number, isMobile: boolean) => void; diff --git a/src/lib/types.ts b/src/lib/types.ts index d5fbd41..55c7c44 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -27,6 +27,8 @@ export type Item = { page?: string; sectionId?: string; + + rotation?: number; }; export type PronounSet = { diff --git a/src/lib/website/EditBar.svelte b/src/lib/website/EditBar.svelte index 6fbf552..43b6599 100644 --- a/src/lib/website/EditBar.svelte +++ b/src/lib/website/EditBar.svelte @@ -30,6 +30,8 @@ ondeselect, ondelete, onsetsize, + allowRotate = false, + onrotate, showSectionsModal }: { data: WebsiteData; @@ -54,6 +56,8 @@ ondeselect?: () => void; ondelete?: () => void; onsetsize?: (w: number, h: number) => void; + allowRotate?: boolean; + onrotate?: (delta: number) => void; showSectionsModal?: () => void; } = $props(); @@ -130,6 +134,14 @@ return w >= minW && w <= maxW && h >= minH && h <= maxH; } + function setSize(w: number, h: number) { + if (isMobile) { + onsetsize?.(w * 2, h * 2); + } else { + onsetsize?.(w, h); + } + } + const showEditControls = $derived(!!selectedCard); @@ -144,211 +156,279 @@ /> {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} - +
{#if showEditControls} - -
- {#if cardDef?.allowSetColor !== false} - + +
+ {#if cardDef?.allowSetColor !== false} + + {#snippet child({ props })} + + {/snippet} + { + if (typeof previous === 'string' || typeof color === 'string') { + return; + } + if (selectedCard) { + selectedCard.color = color.label; + } + }} + class="w-64" + /> + + {/if} + + {#snippet child({ props })} - {/snippet} - { - if (typeof previous === 'string' || typeof color === 'string') { - return; - } - if (selectedCard) { - selectedCard.color = color.label; - } - }} - class="w-64" - /> +
+ {#if canSetSize(2, 2)} + + {/if} + {#if canSetSize(4, 2)} + + {/if} + {#if canSetSize(2, 4)} + + {/if} + {#if canSetSize(4, 4)} + + {/if} +
- {/if} - - {#snippet child({ props })} - + {/snippet} + { + settingsPopoverOpen = false; + }} + /> + + {/if} + + {#if allowRotate} + - {/snippet} -
- {#if canSetSize(2, 2)} - - {/if} - {#if canSetSize(4, 2)} - - {/if} - {#if canSetSize(2, 4)} - - {/if} - {#if canSetSize(4, 4)} - - {/if} -
- - - {#if cardDef?.settingsComponent && selectedCard} - - {#snippet child({ props })} - - {/snippet} - { - settingsPopoverOpen = false; - }} - /> - - {/if} -
-
- + {/if} +
+
+ - + -
- {:else} + + + + +
+ + {/if} + +
-
- {/if} -
- {#if showSectionsModal} - + {/if} +
-
+ {:else} + + {/if} +
+
+
{/if} diff --git a/src/lib/website/EditableWebsite.svelte b/src/lib/website/EditableWebsite.svelte index dfec3d6..d24925a 100644 --- a/src/lib/website/EditableWebsite.svelte +++ b/src/lib/website/EditableWebsite.svelte @@ -570,6 +570,19 @@ onLayoutChanged(); } }} + allowRotate={(() => { + if (!selectedCard) return false; + const section = sections.find((s) => s.id === selectedCard.sectionId); + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; + const cardDef = CardDefinitionsByType[selectedCard.cardType]; + return !!def?.allowRotate && cardDef?.allowRotate !== false; + })()} + onrotate={(delta: number) => { + if (selectedCard) { + selectedCard.rotation = (selectedCard.rotation ?? 0) + delta; + onLayoutChanged(); + } + }} showSectionsModal={SECTIONS_EDITING_ENABLED ? () => { showSectionsModal = true; diff --git a/src/lib/website/EmptyState.svelte b/src/lib/website/EmptyState.svelte index 489b968..3c7aa4c 100644 --- a/src/lib/website/EmptyState.svelte +++ b/src/lib/website/EmptyState.svelte @@ -1,8 +1,7 @@ {#each cards as item (item.id)} - + - + {/each} - -
+
diff --git a/src/lib/website/file-processing.ts b/src/lib/website/file-processing.ts index 0851954..aecfa96 100644 --- a/src/lib/website/file-processing.ts +++ b/src/lib/website/file-processing.ts @@ -55,9 +55,12 @@ export async function createImageCard(file: File, page: string, sectionId?: stri const item = createEmptyCard(page, sectionId); item.cardType = isGif ? 'gif' : 'image'; - item.cardData = { image: { blob: file, objectUrl } }; - const { width, height } = await getImageDimensions(objectUrl); + item.cardData = { + image: { blob: file, objectUrl }, + aspectRatio: { width, height } + }; + const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); item.w = dw; diff --git a/src/lib/website/load.ts b/src/lib/website/load.ts index ae5b977..ee908df 100644 --- a/src/lib/website/load.ts +++ b/src/lib/website/load.ts @@ -236,10 +236,31 @@ export async function loadData( sectionRecords = contrailData.sections; pageRecords = contrailData.pages; - const extracted = extractProfileData(did, contrailData.profiles); - profile = extracted.profile; - publication = extracted.publication; - pronounsRecord = extracted.pronounsRecord; + const hasBskyProfile = contrailData.profiles.some( + (p) => p.did === did && p.collection === 'app.bsky.actor.profile' + ); + + if (hasBskyProfile) { + const extracted = extractProfileData(did, contrailData.profiles); + profile = extracted.profile; + publication = extracted.publication; + pronounsRecord = extracted.pronounsRecord; + } else { + // Contrail didn't return profile data (e.g. no card records) — fetch from PDS + const [prof, mainPub, pronouns] = await Promise.all([ + getDetailedProfile({ did }), + getSelfPublicationFromPDS(did), + getPronounsFromPDS(did) + ]); + profile = prof; + publication = mainPub?.value as WebsiteData['publication'] | undefined; + pronounsRecord = pronouns; + + // Still extract publication/pronouns from contrail if available + const extracted = extractProfileData(did, contrailData.profiles); + if (!publication) publication = extracted.publication; + if (!pronounsRecord) pronounsRecord = extracted.pronounsRecord; + } } else { // Fallback: no D1 available (e.g. vite dev) — use PDS directly const [cardRecords, pageRecs, sectionRecs, mainPub, prof, pronouns] = await Promise.all([ @@ -330,10 +351,29 @@ export async function loadCardData( cardValue = card; if (profiles) { - const extracted = extractProfileData(did, profiles); - profile = extracted.profile; - publication = extracted.publication; - pronounsRecord = extracted.pronounsRecord; + const hasBskyProfile = profiles.some( + (p) => p.did === did && p.collection === 'app.bsky.actor.profile' + ); + if (hasBskyProfile) { + const extracted = extractProfileData(did, profiles); + profile = extracted.profile; + publication = extracted.publication; + pronounsRecord = extracted.pronounsRecord; + } else { + // Contrail didn't return bsky profile — fetch from PDS + const [prof, mainPub, pronouns] = await Promise.all([ + getDetailedProfile({ did }), + getSelfPublicationFromPDS(did), + getPronounsFromPDS(did) + ]); + profile = prof; + publication = mainPub?.value as WebsiteData['publication'] | undefined; + pronounsRecord = pronouns; + + const extracted = extractProfileData(did, profiles); + if (!publication) publication = extracted.publication; + if (!pronounsRecord) pronounsRecord = extracted.pronounsRecord; + } } } @@ -419,10 +459,19 @@ export async function loadCardTypeData( if (db) { const profiles = await loadProfilesFromContrail(handle, db); if (profiles) { - const extracted = extractProfileData(did, profiles); - profile = extracted.profile; - publication = extracted.publication; - pronounsRecord = extracted.pronounsRecord; + const hasBskyProfile = profiles.some( + (p) => p.did === did && p.collection === 'app.bsky.actor.profile' + ); + if (hasBskyProfile) { + const extracted = extractProfileData(did, profiles); + profile = extracted.profile; + publication = extracted.publication; + pronounsRecord = extracted.pronounsRecord; + } else { + const extracted = extractProfileData(did, profiles); + publication = extracted.publication; + pronounsRecord = extracted.pronounsRecord; + } } } @@ -433,8 +482,8 @@ export async function loadCardTypeData( getPronounsFromPDS(did) ]); profile = prof; - publication = pubRecord?.value as WebsiteData['publication'] | undefined; - pronounsRecord = pronouns; + if (!publication) publication = pubRecord?.value as WebsiteData['publication'] | undefined; + if (!pronounsRecord) pronounsRecord = pronouns; } if (!profile) throw error(404); diff --git a/wrangler.jsonc b/wrangler.jsonc index 03c258c..036969c 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -60,8 +60,8 @@ { "binding": "DB", "database_name": "blento", - "database_id": "922639e7-6321-42c8-a4bd-cdf48428fdac" - // "remote": true + "database_id": "922639e7-6321-42c8-a4bd-cdf48428fdac", + "remote": true } ], "triggers": {