your personal website on atproto - mirror blento.app
23
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix stuff we broke, add more sections

Florian 67308c9b a2ebb80f

+1161 -372
+105
docs/todo/external-section-sources.md
··· 1 + # External data sources for sections 2 + 3 + Allow sections to be auto-filled from external data (e.g. Grain gallery photos, standard.site blog posts) instead of manually curated cards. 4 + 5 + ## Goals 6 + 7 + - Sections can pull items from external sources (ATProto collections, web APIs, etc.) without the user manually creating each card. 8 + - Extension path mirrors the existing card `loadData` pattern so there's one consistent idea in the codebase. 9 + - A section can always fall back to manual content (same UX as today). 10 + - Multiple sections on a page can use different sources; a single page can mix sourced and manual sections. 11 + 12 + ## Proposed API 13 + 14 + ### SectionDefinition additions 15 + 16 + ```ts 17 + type SectionDefinition = { 18 + // existing... 19 + 20 + // Optional external data fetch. Result stored in WebsiteData.additionalData[section.id]. 21 + loadData?: (section: SectionRecord, ctx: { did; handle; cache? }) => Promise<unknown>; 22 + loadDataServer?: ( 23 + section: SectionRecord, 24 + ctx: { did; handle; cache?; env?; platform? } 25 + ) => Promise<unknown>; 26 + }; 27 + ``` 28 + 29 + Mirrors the card-level `loadData`/`loadDataServer`. Reuses the same cache service + KV infrastructure. 30 + 31 + ### sectionData shape 32 + 33 + Each section that supports sourcing stores its source config in `sectionData`: 34 + 35 + ```ts 36 + // Manual (default) 37 + sectionData: { source: { provider: 'manual' }, ...otherStuff } 38 + 39 + // Sourced from Grain 40 + sectionData: { 41 + source: { provider: 'grain-gallery', galleryUri: 'at://did:.../xyz.grain.photo.gallery/abc' } 42 + } 43 + 44 + // Sourced from standard.site blog 45 + sectionData: { 46 + source: { provider: 'standard-site-blog', did: 'did:...', limit: 10 } 47 + } 48 + ``` 49 + 50 + ### Source registration 51 + 52 + Sources are pluggable modules that can be used by one or more section types: 53 + 54 + ```ts 55 + // src/lib/sections/sources/types.ts 56 + type SectionSource = { 57 + id: string; // e.g. 'grain-gallery' 58 + name: string; // user-facing name 59 + supportedSections: string[]; // e.g. ['gallery'] 60 + configComponent: Component<{ 61 + config: any; 62 + onchange: (next: any) => void; 63 + }>; 64 + loadData?: (config: any, ctx: any) => Promise<unknown>; 65 + loadDataServer?: (config: any, ctx: any) => Promise<unknown>; 66 + }; 67 + ``` 68 + 69 + Registered in a central `AllSectionSources` array like cards/sections are. Section types query the list filtered by `supportedSections`. 70 + 71 + Example: `src/lib/sections/sources/grain-gallery.ts` registers a source with `supportedSections: ['gallery']` and a `loadDataServer` that fetches photos via the Grain lexicon. 72 + 73 + ### Rendering flow 74 + 75 + The section's `contentComponent` receives: 76 + 77 + - `items` (manual cards assigned to the section, as today) 78 + - `externalData` (from `additionalData[section.id]`) 79 + 80 + 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). 81 + 82 + 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. 83 + 84 + ### Editing UX 85 + 86 + - Section settings popover/drawer gets a **Source** dropdown listing compatible sources (derived from `AllSectionSources` filtered to the current section type) 87 + - When the source is non-manual, the inline card-editing UI is replaced with the source's `configComponent` (e.g. "Paste Grain gallery URI") 88 + - Switching source back to "Manual" restores the manual editing UX (and the existing cards resurface) 89 + - The source's config is persisted in `sectionData.source` 90 + 91 + ## Implementation order 92 + 93 + 1. Add `loadData` / `loadDataServer` to `SectionDefinition` and wire into `load.ts` (server-side fan-out like cards already do). 94 + 2. Add the sources registry (`src/lib/sections/sources/index.ts`). 95 + 3. Teach `contentComponent` / `editingContentComponent` to branch on `sectionData.source.provider`. 96 + 4. Build one real source end-to-end: **Grain gallery → Gallery section** (infrastructure already exists in `PhotoGalleryCard`). 97 + 5. Follow with: **standard.site blog → Row section** (requires a blog-post card first, or a synthetic link card with richer metadata). 98 + 99 + ## Open questions 100 + 101 + - Should sources be able to return card-type-specific `cardData`, or should they always return a normalised shape? Probably card-type-specific for flexibility. 102 + - Pagination: do sources need to know about "load more" or infinite scroll? For now assume batch-fetch (limit in config). 103 + - 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. 104 + - Mixing sourced + manual: start with mutually exclusive (source OR manual), add mixing later if needed. 105 + - Mobile UX for the Source dropdown and config UI: same treatment as the card editing popovers.
+1
lexicons/app/blento/card.json
··· 23 23 "color": { "type": "string" }, 24 24 "page": { "type": "string" }, 25 25 "sectionId": { "type": "string" }, 26 + "rotation": { "type": "integer" }, 26 27 "updatedAt": { "type": "string", "format": "datetime" }, 27 28 "version": { "type": "integer" } 28 29 }
+3
src/lib/cards/types.ts
··· 80 80 81 81 canResize?: boolean; 82 82 83 + // allow rotation for this card type (default true — section can also disable) 84 + allowRotate?: boolean; 85 + 83 86 // if true, content can render outside the card's bounds (no overflow:hidden on the inner wrapper) 84 87 noOverflow?: boolean; 85 88
+153
src/lib/sections/GallerySection/EditingGallerySection.svelte
··· 1 + <script lang="ts"> 2 + import { ImageMasonry } from '@foxui/visual'; 3 + import { Button } from '@foxui/core'; 4 + import { getDidContext, getSelectCard } from '$lib/website/context'; 5 + import { getImage } from '$lib/helper'; 6 + import type { EditingSectionContentProps } from '../types'; 7 + import SectionChrome from '../SectionChrome.svelte'; 8 + 9 + let { 10 + section, 11 + items = $bindable(), 12 + isActive, 13 + onlayoutchange, 14 + oncreatefilecards, 15 + onactivate, 16 + onrefchange 17 + }: EditingSectionContentProps = $props(); 18 + 19 + let d = $derived(section.sectionData); 20 + let columns = $derived(Math.max(1, Math.min(6, (d.columns ?? 3) as number))); 21 + 22 + const did = getDidContext(); 23 + const selectCard = getSelectCard(); 24 + 25 + let sectionItems = $derived( 26 + items.filter((i) => i.sectionId === section.id).toSorted((a, b) => a.x - b.x) 27 + ); 28 + 29 + let images = $derived( 30 + sectionItems 31 + .map((item) => { 32 + const src = getImage(item.cardData, did, 'image'); 33 + if (!src) return null; 34 + const ar = item.cardData.aspectRatio as { width: number; height: number } | undefined; 35 + const width = ar?.width || item.w || 1; 36 + const height = ar?.height || item.h || 1; 37 + return { 38 + src, 39 + name: '', 40 + width, 41 + height, 42 + onclick: () => selectCard(item.id) 43 + }; 44 + }) 45 + .filter((i) => i !== null) 46 + ); 47 + 48 + let containerRef: HTMLDivElement | undefined = $state(); 49 + let hovered = $state(false); 50 + 51 + $effect(() => { 52 + onrefchange(containerRef); 53 + return () => onrefchange(undefined); 54 + }); 55 + 56 + $effect(() => { 57 + const el = containerRef; 58 + if (!el) return; 59 + const enter = () => (hovered = true); 60 + const leave = () => (hovered = false); 61 + const down = () => onactivate(); 62 + el.addEventListener('pointerenter', enter); 63 + el.addEventListener('pointerleave', leave); 64 + el.addEventListener('pointerdown', down); 65 + return () => { 66 + el.removeEventListener('pointerenter', enter); 67 + el.removeEventListener('pointerleave', leave); 68 + el.removeEventListener('pointerdown', down); 69 + }; 70 + }); 71 + 72 + const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`; 73 + 74 + let fileInput: HTMLInputElement | undefined = $state(); 75 + 76 + async function handleFiles(event: Event) { 77 + const input = event.target as HTMLInputElement; 78 + if (!input.files || input.files.length < 1) return; 79 + const files = Array.from(input.files).filter((f) => f.type.startsWith('image/')); 80 + const cards = await oncreatefilecards(files); 81 + for (const card of cards) { 82 + card.sectionId = section.id; 83 + card.w = 2; 84 + card.h = 2; 85 + card.mobileW = 4; 86 + card.mobileH = 4; 87 + const current = items.filter((i) => i.sectionId === section.id); 88 + card.x = current.length; 89 + card.y = 0; 90 + items = [...items, card]; 91 + } 92 + onlayoutchange(); 93 + input.value = ''; 94 + } 95 + </script> 96 + 97 + <input 98 + bind:this={fileInput} 99 + type="file" 100 + accept="image/*" 101 + multiple 102 + class="hidden" 103 + onchange={handleFiles} 104 + /> 105 + 106 + <div 107 + bind:this={containerRef} 108 + class="@container/grid pointer-events-auto relative col-span-3 px-2 py-8" 109 + > 110 + <SectionChrome {isActive} {hovered} name={section.name || 'Gallery'} {icon} /> 111 + 112 + {#if sectionItems.length > 0} 113 + <div class="gallery-compact"> 114 + <ImageMasonry {images} showNames={false} maxColumns={2} /> 115 + </div> 116 + <div class="gallery-wide"> 117 + <ImageMasonry {images} showNames={false} maxColumns={columns} /> 118 + </div> 119 + {/if} 120 + 121 + <div class="mt-4 flex justify-center"> 122 + <Button variant="ghost" onclick={() => fileInput?.click()} class="gap-2"> 123 + <svg 124 + xmlns="http://www.w3.org/2000/svg" 125 + viewBox="0 0 24 24" 126 + fill="none" 127 + stroke="currentColor" 128 + stroke-width="2" 129 + stroke-linecap="round" 130 + stroke-linejoin="round" 131 + class="size-4" 132 + > 133 + <path d="M12 5v14" /> 134 + <path d="M5 12h14" /> 135 + </svg> 136 + Add images 137 + </Button> 138 + </div> 139 + </div> 140 + 141 + <style> 142 + .gallery-wide { 143 + display: none; 144 + } 145 + @container grid (width >= 42rem) { 146 + .gallery-compact { 147 + display: none; 148 + } 149 + .gallery-wide { 150 + display: block; 151 + } 152 + } 153 + </style>
+60
src/lib/sections/GallerySection/GallerySection.svelte
··· 1 + <script lang="ts"> 2 + import { ImageMasonry } from '@foxui/visual'; 3 + import { getDidContext } from '$lib/website/context'; 4 + import { getImage } from '$lib/helper'; 5 + import { openImageViewer } from '$lib/components/image-viewer/imageViewer.svelte'; 6 + import type { SectionContentProps } from '../types'; 7 + 8 + let { section, items }: SectionContentProps = $props(); 9 + 10 + let d = $derived(section.sectionData); 11 + let columns = $derived(Math.max(1, Math.min(6, (d.columns ?? 3) as number))); 12 + 13 + const did = getDidContext(); 14 + 15 + let sectionItems = $derived( 16 + items.filter((i) => i.sectionId === section.id).toSorted((a, b) => a.x - b.x) 17 + ); 18 + 19 + let images = $derived( 20 + sectionItems 21 + .map((item) => { 22 + const src = getImage(item.cardData, did, 'image'); 23 + if (!src) return null; 24 + const ar = item.cardData.aspectRatio as { width: number; height: number } | undefined; 25 + const width = ar?.width || item.w || 1; 26 + const height = ar?.height || item.h || 1; 27 + return { 28 + src, 29 + name: '', 30 + width, 31 + height, 32 + onclick: () => openImageViewer(src) 33 + }; 34 + }) 35 + .filter((i) => i !== null) 36 + ); 37 + </script> 38 + 39 + <div class="@container/grid relative col-span-3 px-2 py-8"> 40 + <div class="gallery-compact"> 41 + <ImageMasonry {images} showNames={false} maxColumns={2} /> 42 + </div> 43 + <div class="gallery-wide"> 44 + <ImageMasonry {images} showNames={false} maxColumns={columns} /> 45 + </div> 46 + </div> 47 + 48 + <style> 49 + .gallery-wide { 50 + display: none; 51 + } 52 + @container grid (width >= 42rem) { 53 + .gallery-compact { 54 + display: none; 55 + } 56 + .gallery-wide { 57 + display: block; 58 + } 59 + } 60 + </style>
+34
src/lib/sections/GallerySection/index.ts
··· 1 + import type { SectionDefinition } from '../types'; 2 + import EditingGallerySection from './EditingGallerySection.svelte'; 3 + import GallerySection from './GallerySection.svelte'; 4 + 5 + export function defaultGallerySectionData(): Record<string, any> { 6 + return { 7 + columns: 3, 8 + gap: 2 9 + }; 10 + } 11 + 12 + export const GallerySectionDefinition: SectionDefinition = { 13 + type: 'gallery', 14 + contentComponent: GallerySection, 15 + editingContentComponent: EditingGallerySection, 16 + defaultSectionData: defaultGallerySectionData, 17 + cardFilter: (def) => def.type === 'image' || def.type === 'gif', 18 + addItem: (item, allItems) => { 19 + item.w = 2; 20 + item.h = 2; 21 + item.mobileW = 4; 22 + item.mobileH = 4; 23 + const sectionItems = allItems.filter((i) => i.sectionId === item.sectionId); 24 + item.x = sectionItems.length; 25 + item.y = 0; 26 + item.mobileX = 0; 27 + item.mobileY = sectionItems.length; 28 + return [...allItems, item]; 29 + }, 30 + deleteItem: (itemId, allItems) => allItems.filter((i) => i.id !== itemId), 31 + resizeItem: () => {}, 32 + name: 'Gallery', 33 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>` 34 + };
+8 -21
src/lib/sections/GridSection/EditingGridSection.svelte
··· 4 4 import { EditableGrid, fixCollisions, compactItems, setPositionOfNewItem } from '$lib/layout'; 5 5 import GridBaseEditingCard from '$lib/cards/_base/BaseCard/GridBaseEditingCard.svelte'; 6 6 import EditingCard from '$lib/cards/_base/Card/EditingCard.svelte'; 7 - import { SectionDefinitionsByType } from '$lib/sections'; 8 - import { SECTIONS_EDITING_ENABLED } from '$lib/sections/feature-flag'; 7 + import SectionChrome from '../SectionChrome.svelte'; 8 + import { GRID_SECTION_ICON, GRID_SECTION_NAME } from './shared'; 9 9 import { positionItemAtGridPos } from './add-item'; 10 10 11 11 let { ··· 28 28 let sectionItems = $derived(items.filter((i) => i.sectionId === section.id)); 29 29 30 30 let hovered = $state(false); 31 - const def = $derived(SectionDefinitionsByType[section.sectionType]); 32 31 33 32 $effect(() => { 34 33 onrefchange(gridRef); ··· 85 84 {ondeselect} 86 85 onfiledrop={handleFileDrop} 87 86 > 88 - {#if SECTIONS_EDITING_ENABLED && (hovered || isActive)} 89 - <div 90 - class="pointer-events-none absolute inset-0 z-30 rounded-3xl border-2 border-dashed transition-colors duration-150 {isActive 91 - ? 'border-accent-500/50' 92 - : 'border-base-400/30 dark:border-base-500/30'}" 93 - > 94 - <div 95 - class="bg-base-100/80 dark:bg-base-900/80 absolute -top-3 left-4 flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium backdrop-blur-sm {isActive 96 - ? 'text-accent-600 dark:text-accent-400' 97 - : 'text-base-500 dark:text-base-400'}" 98 - > 99 - {#if def?.icon} 100 - <span class="[&_svg]:size-3">{@html def.icon}</span> 101 - {/if} 102 - {section.name || def?.name || section.sectionType} 103 - </div> 104 - </div> 105 - {/if} 87 + <SectionChrome 88 + {isActive} 89 + {hovered} 90 + name={section.name || GRID_SECTION_NAME} 91 + icon={GRID_SECTION_ICON} 92 + /> 106 93 107 94 {#if sectionItems.length === 0} 108 95 <div
+5 -2
src/lib/sections/GridSection/index.ts
··· 4 4 import { addItemToGridSection } from './add-item'; 5 5 import EditingGridSection from './EditingGridSection.svelte'; 6 6 import GridSection from './GridSection.svelte'; 7 + import { GRID_SECTION_NAME, GRID_SECTION_ICON } from './shared'; 8 + 9 + export * from './shared'; 7 10 8 11 function getSectionItems(allItems: Item[], sectionId: string) { 9 12 return allItems.filter((i) => i.sectionId === sectionId); ··· 32 35 const sectionItems = getSectionItems(allItems, item.sectionId!); 33 36 fixCollisions(sectionItems, item, isMobile); 34 37 }, 35 - name: 'Grid', 36 - icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>` 38 + name: GRID_SECTION_NAME, 39 + icon: GRID_SECTION_ICON 37 40 };
+2
src/lib/sections/GridSection/shared.ts
··· 1 + export const GRID_SECTION_NAME = 'Grid'; 2 + export const GRID_SECTION_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`;
+5 -3
src/lib/sections/HeroSection/Decoration.svelte
··· 1 1 <script lang="ts"> 2 - import type { DecorationSlot } from '.'; 2 + import type { DecorationSlot } from './shared'; 3 3 import type { Item } from '$lib/types'; 4 4 import Card from '$lib/cards/_base/Card/Card.svelte'; 5 5 import EditingCard from '$lib/cards/_base/Card/EditingCard.svelte'; ··· 24 24 const selectedCardId = getSelectedCardId(); 25 25 let isSelected = $derived(!!item && selectedCardId?.() === item.id); 26 26 27 + let rotation = $derived(item?.rotation ?? slot.rotation ?? 0); 28 + 27 29 const sideStyle = $derived( 28 30 slot.side === 'left' 29 - ? `left: 0; transform: translate(calc(-1 * var(--tx)), -50%) rotate(${slot.rotation ?? 0}deg);` 30 - : `right: 0; transform: translate(var(--tx), -50%) rotate(${slot.rotation ?? 0}deg);` 31 + ? `left: 0; transform: translate(calc(-1 * var(--tx)), -50%) rotate(${rotation}deg);` 32 + : `right: 0; transform: translate(var(--tx), -50%) rotate(${rotation}deg);` 31 33 ); 32 34 </script> 33 35
+6 -29
src/lib/sections/HeroSection/EditingHeroSection.svelte
··· 15 15 import type { EditingSectionContentProps } from '../types'; 16 16 import { 17 17 DEFAULT_DECORATION_SLOTS, 18 - canFitInSlot, 19 18 getSlotAssignments, 20 19 getSlotItem, 21 20 heroAlignClasses, 22 21 heroVerticalAlignClasses 23 - } from '.'; 22 + } from './shared'; 24 23 import Decoration from './Decoration.svelte'; 24 + import SectionChrome from '../SectionChrome.svelte'; 25 + 26 + const heroIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 10h10"/><path d="M7 14h6"/></svg>`; 25 27 26 28 let { 27 29 section, ··· 142 144 bind:this={containerRef} 143 145 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" 144 146 > 147 + <SectionChrome {isActive} {hovered} name={section.name || 'Hero'} icon={heroIcon} /> 148 + 145 149 {#if hovered || isActive} 146 - <div 147 - class="pointer-events-none absolute inset-0 z-30 rounded-3xl border-2 border-dashed transition-colors duration-150 {isActive 148 - ? 'border-accent-500/50' 149 - : 'border-base-400/30 dark:border-base-500/30'}" 150 - > 151 - <div 152 - class="bg-base-100/80 dark:bg-base-900/80 absolute -top-3 left-4 flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium backdrop-blur-sm {isActive 153 - ? 'text-accent-600 dark:text-accent-400' 154 - : 'text-base-500 dark:text-base-400'}" 155 - > 156 - <svg 157 - xmlns="http://www.w3.org/2000/svg" 158 - viewBox="0 0 24 24" 159 - fill="none" 160 - stroke="currentColor" 161 - stroke-width="2" 162 - stroke-linecap="round" 163 - stroke-linejoin="round" 164 - class="size-3" 165 - ><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 10h10" /><path 166 - d="M7 14h6" 167 - /></svg 168 - > 169 - Hero 170 - </div> 171 - </div> 172 - 173 150 <div class="pointer-events-auto absolute -top-3 right-4 z-40"> 174 151 <Popover bind:open={settingsOpen}> 175 152 {#snippet child({ props })}
+1 -1
src/lib/sections/HeroSection/HeroSection.svelte
··· 11 11 getSlotItem, 12 12 heroAlignClasses, 13 13 heroVerticalAlignClasses 14 - } from '.'; 14 + } from './shared'; 15 15 import Decoration from './Decoration.svelte'; 16 16 17 17 let { section, items }: SectionContentProps = $props();
+12 -67
src/lib/sections/HeroSection/index.ts
··· 1 - import type { Item, SectionRecord } from '$lib/types'; 1 + import type { Item } from '$lib/types'; 2 2 import type { SectionDefinition } from '../types'; 3 3 import EditingHeroSection from './EditingHeroSection.svelte'; 4 4 import HeroSection from './HeroSection.svelte'; 5 - 6 - export type DecorationSlot = { 7 - id: string; 8 - side: 'left' | 'right'; 9 - top: number; 10 - rotation: number; 11 - }; 12 - 13 - export const DEFAULT_DECORATION_SLOTS: DecorationSlot[] = [ 14 - { id: 'slot-l-0', side: 'left', top: 15, rotation: -10 }, 15 - { id: 'slot-l-1', side: 'left', top: 50, rotation: -4 }, 16 - { id: 'slot-l-2', side: 'left', top: 82, rotation: -12 }, 17 - { id: 'slot-r-0', side: 'right', top: 18, rotation: 8 }, 18 - { id: 'slot-r-1', side: 'right', top: 52, rotation: -12 }, 19 - { id: 'slot-r-2', side: 'right', top: 80, rotation: 6 } 20 - ]; 21 - 22 - export function getSlotAssignments(sectionData: Record<string, any>): Record<string, string> { 23 - return (sectionData.slotAssignments as Record<string, string>) ?? {}; 24 - } 25 - 26 - export function getSlotItem( 27 - slot: DecorationSlot, 28 - assignments: Record<string, string>, 29 - items: Item[] 30 - ): Item | undefined { 31 - const itemId = assignments[slot.id]; 32 - if (!itemId) return undefined; 33 - return items.find((i) => i.id === itemId); 34 - } 5 + import { DEFAULT_DECORATION_SLOTS, canFitInSlot, defaultHeroSectionData } from './shared'; 35 6 36 - export function canFitInSlot(cardDef: { minW?: number; minH?: number }): boolean { 37 - return (cardDef.minW ?? 2) <= 2 && (cardDef.minH ?? 2) <= 2; 38 - } 39 - 40 - export function defaultHeroSectionData(): Record<string, any> { 41 - return { 42 - title: 'My cool website', 43 - subtitle: 'A little something about me, what I do, and why you should stick around.', 44 - badge: 'Welcome', 45 - buttonText: 'Say hi', 46 - buttonHref: '', 47 - showBadge: true, 48 - showSubtitle: true, 49 - showButton: true, 50 - textAlign: 'center', 51 - verticalAlign: 'center', 52 - slotAssignments: {} 53 - }; 54 - } 55 - 56 - export const heroAlignClasses: Record<string, string> = { 57 - left: 'text-left items-start', 58 - center: 'text-center items-center', 59 - right: 'text-right items-end' 60 - }; 61 - 62 - export const heroVerticalAlignClasses: Record<string, string> = { 63 - top: 'justify-start', 64 - center: 'justify-center', 65 - bottom: 'justify-end' 66 - }; 7 + export * from './shared'; 67 8 68 9 export const HeroSectionDefinition: SectionDefinition = { 69 10 type: 'hero', ··· 71 12 editingContentComponent: EditingHeroSection, 72 13 defaultSectionData: defaultHeroSectionData, 73 14 cardFilter: canFitInSlot, 15 + allowRotate: true, 74 16 addItem: (item: Item, allItems: Item[], options) => { 75 17 item.w = 2; 76 18 item.h = 2; 77 19 item.mobileW = 2; 78 20 item.mobileH = 2; 79 - if (options.extraData?.slotId) { 80 - item.cardData._slotId = options.extraData.slotId; 21 + const slotId = options.extraData?.slotId; 22 + if (slotId) { 23 + item.cardData._slotId = slotId; 24 + const slot = DEFAULT_DECORATION_SLOTS.find((s) => s.id === slotId); 25 + if (slot && item.rotation === undefined) { 26 + item.rotation = slot.rotation; 27 + } 81 28 } 82 29 return [...allItems, item]; 83 30 }, 84 - deleteItem: (itemId: string, allItems: Item[]) => { 85 - return allItems.filter((i) => i.id !== itemId); 86 - }, 31 + deleteItem: (itemId: string, allItems: Item[]) => allItems.filter((i) => i.id !== itemId), 87 32 resizeItem: () => {}, 88 33 name: 'Hero', 89 34 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 10h10"/><path d="M7 14h6"/></svg>`
+63
src/lib/sections/HeroSection/shared.ts
··· 1 + import type { Item } from '$lib/types'; 2 + 3 + export type DecorationSlot = { 4 + id: string; 5 + side: 'left' | 'right'; 6 + top: number; 7 + rotation: number; 8 + }; 9 + 10 + export const DEFAULT_DECORATION_SLOTS: DecorationSlot[] = [ 11 + { id: 'slot-l-0', side: 'left', top: 15, rotation: -10 }, 12 + { id: 'slot-l-1', side: 'left', top: 50, rotation: -4 }, 13 + { id: 'slot-l-2', side: 'left', top: 82, rotation: -12 }, 14 + { id: 'slot-r-0', side: 'right', top: 18, rotation: 8 }, 15 + { id: 'slot-r-1', side: 'right', top: 52, rotation: -12 }, 16 + { id: 'slot-r-2', side: 'right', top: 80, rotation: 6 } 17 + ]; 18 + 19 + export function getSlotAssignments(sectionData: Record<string, any>): Record<string, string> { 20 + return (sectionData.slotAssignments as Record<string, string>) ?? {}; 21 + } 22 + 23 + export function getSlotItem( 24 + slot: DecorationSlot, 25 + assignments: Record<string, string>, 26 + items: Item[] 27 + ): Item | undefined { 28 + const itemId = assignments[slot.id]; 29 + if (!itemId) return undefined; 30 + return items.find((i) => i.id === itemId); 31 + } 32 + 33 + export function canFitInSlot(cardDef: { minW?: number; minH?: number }): boolean { 34 + return (cardDef.minW ?? 2) <= 2 && (cardDef.minH ?? 2) <= 2; 35 + } 36 + 37 + export function defaultHeroSectionData(): Record<string, any> { 38 + return { 39 + title: 'My cool website', 40 + subtitle: 'A little something about me, what I do, and why you should stick around.', 41 + badge: 'Welcome', 42 + buttonText: 'Say hi', 43 + buttonHref: '', 44 + showBadge: true, 45 + showSubtitle: true, 46 + showButton: true, 47 + textAlign: 'center', 48 + verticalAlign: 'center', 49 + slotAssignments: {} 50 + }; 51 + } 52 + 53 + export const heroAlignClasses: Record<string, string> = { 54 + left: 'text-left items-start', 55 + center: 'text-center items-center', 56 + right: 'text-right items-end' 57 + }; 58 + 59 + export const heroVerticalAlignClasses: Record<string, string> = { 60 + top: 'justify-start', 61 + center: 'justify-center', 62 + bottom: 'justify-end' 63 + };
+100
src/lib/sections/RowSection/EditingRowSection.svelte
··· 1 + <script lang="ts"> 2 + import BaseEditingCard from '$lib/cards/_base/BaseCard/BaseEditingCard.svelte'; 3 + import EditingCard from '$lib/cards/_base/Card/EditingCard.svelte'; 4 + import type { EditingSectionContentProps } from '../types'; 5 + import SectionChrome from '../SectionChrome.svelte'; 6 + 7 + let { 8 + section, 9 + items = $bindable(), 10 + isActive, 11 + onlayoutchange, 12 + onrequestaddcard, 13 + onactivate, 14 + onrefchange 15 + }: EditingSectionContentProps = $props(); 16 + 17 + let sectionItems = $derived( 18 + items.filter((i) => i.sectionId === section.id).toSorted((a, b) => a.x - b.x) 19 + ); 20 + 21 + let containerRef: HTMLDivElement | undefined = $state(); 22 + let hovered = $state(false); 23 + 24 + $effect(() => { 25 + onrefchange(containerRef); 26 + return () => onrefchange(undefined); 27 + }); 28 + 29 + $effect(() => { 30 + const el = containerRef; 31 + if (!el) return; 32 + const enter = () => (hovered = true); 33 + const leave = () => (hovered = false); 34 + const down = () => onactivate(); 35 + el.addEventListener('pointerenter', enter); 36 + el.addEventListener('pointerleave', leave); 37 + el.addEventListener('pointerdown', down); 38 + return () => { 39 + el.removeEventListener('pointerenter', enter); 40 + el.removeEventListener('pointerleave', leave); 41 + el.removeEventListener('pointerdown', down); 42 + }; 43 + }); 44 + 45 + function deleteItem(id: string) { 46 + items = items.filter((i) => i.id !== id); 47 + onlayoutchange(); 48 + } 49 + 50 + const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="8" width="5" height="8" rx="1"/><rect x="10" y="8" width="5" height="8" rx="1"/><rect x="17" y="8" width="5" height="8" rx="1"/></svg>`; 51 + </script> 52 + 53 + <div 54 + bind:this={containerRef} 55 + class="@container/grid pointer-events-auto relative col-span-3 px-0 py-4" 56 + > 57 + <SectionChrome {isActive} {hovered} name={section.name || 'Row'} {icon} /> 58 + 59 + <div class="overflow-x-auto pt-4 pb-14"> 60 + <div class="flex items-stretch gap-4 px-2"> 61 + {#each sectionItems as item (item.id)} 62 + {@const idx = items.indexOf(item)} 63 + <div 64 + class="aspect-square w-40 flex-shrink-0 sm:w-48" 65 + style={item.rotation ? `transform: rotate(${item.rotation}deg);` : ''} 66 + > 67 + <BaseEditingCard 68 + bind:item={items[idx]} 69 + ondelete={() => deleteItem(item.id)} 70 + showGridControls={false} 71 + > 72 + <EditingCard bind:item={items[idx]} /> 73 + </BaseEditingCard> 74 + </div> 75 + {/each} 76 + 77 + <button 78 + type="button" 79 + class="border-base-400/60 dark:border-base-500/60 hover:border-accent-500 hover:bg-accent-500/10 text-base-500 dark:text-base-400 hover:text-accent-600 dark:hover:text-accent-400 flex aspect-square w-40 flex-shrink-0 cursor-pointer flex-col items-center justify-center gap-1 rounded-3xl border-2 border-dashed transition-all duration-150 hover:scale-[1.02] sm:w-48" 80 + onclick={() => onrequestaddcard()} 81 + aria-label="Add card" 82 + > 83 + <svg 84 + xmlns="http://www.w3.org/2000/svg" 85 + viewBox="0 0 24 24" 86 + fill="none" 87 + stroke="currentColor" 88 + stroke-width="2" 89 + stroke-linecap="round" 90 + stroke-linejoin="round" 91 + class="size-6" 92 + > 93 + <path d="M12 5v14" /> 94 + <path d="M5 12h14" /> 95 + </svg> 96 + <span class="text-xs font-medium">Add card</span> 97 + </button> 98 + </div> 99 + </div> 100 + </div>
+28
src/lib/sections/RowSection/RowSection.svelte
··· 1 + <script lang="ts"> 2 + import BaseCard from '$lib/cards/_base/BaseCard/BaseCard.svelte'; 3 + import Card from '$lib/cards/_base/Card/Card.svelte'; 4 + import type { SectionContentProps } from '../types'; 5 + 6 + let { section, items }: SectionContentProps = $props(); 7 + 8 + let sectionItems = $derived( 9 + items.filter((i) => i.sectionId === section.id).toSorted((a, b) => a.x - b.x) 10 + ); 11 + </script> 12 + 13 + <div class="@container/grid relative col-span-3 px-0 py-8"> 14 + <div class="overflow-x-auto"> 15 + <div class="flex gap-4 px-2"> 16 + {#each sectionItems as item (item.id)} 17 + <div 18 + class="aspect-square w-40 flex-shrink-0 sm:w-48" 19 + style={item.rotation ? `transform: rotate(${item.rotation}deg);` : ''} 20 + > 21 + <BaseCard {item}> 22 + <Card {item} /> 23 + </BaseCard> 24 + </div> 25 + {/each} 26 + </div> 27 + </div> 28 + </div>
+34
src/lib/sections/RowSection/index.ts
··· 1 + import type { SectionDefinition } from '../types'; 2 + import EditingRowSection from './EditingRowSection.svelte'; 3 + import RowSection from './RowSection.svelte'; 4 + 5 + export function defaultRowSectionData(): Record<string, any> { 6 + return { 7 + scrollMode: 'scroll' // 'scroll' | 'fit' 8 + }; 9 + } 10 + 11 + export const RowSectionDefinition: SectionDefinition = { 12 + type: 'row', 13 + contentComponent: RowSection, 14 + editingContentComponent: EditingRowSection, 15 + defaultSectionData: defaultRowSectionData, 16 + cardFilter: (def) => (def.minW ?? 2) <= 2 && (def.minH ?? 2) <= 2, 17 + allowRotate: true, 18 + addItem: (item, allItems) => { 19 + item.w = 2; 20 + item.h = 2; 21 + item.mobileW = 4; 22 + item.mobileH = 4; 23 + const sectionItems = allItems.filter((i) => i.sectionId === item.sectionId); 24 + item.x = sectionItems.length; 25 + item.y = 0; 26 + item.mobileX = 0; 27 + item.mobileY = sectionItems.length; 28 + return [...allItems, item]; 29 + }, 30 + deleteItem: (itemId, allItems) => allItems.filter((i) => i.id !== itemId), 31 + resizeItem: () => {}, 32 + name: 'Row', 33 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="8" width="5" height="8" rx="1"/><rect x="10" y="8" width="5" height="8" rx="1"/><rect x="17" y="8" width="5" height="8" rx="1"/></svg>` 34 + };
+34
src/lib/sections/SectionChrome.svelte
··· 1 + <script lang="ts"> 2 + import { SECTIONS_EDITING_ENABLED } from './feature-flag'; 3 + 4 + let { 5 + isActive, 6 + hovered, 7 + name, 8 + icon 9 + }: { 10 + isActive: boolean; 11 + hovered: boolean; 12 + name: string; 13 + icon?: string; 14 + } = $props(); 15 + </script> 16 + 17 + {#if SECTIONS_EDITING_ENABLED && (hovered || isActive)} 18 + <div 19 + class="pointer-events-none absolute inset-0 z-30 rounded-3xl border-2 border-dashed transition-colors duration-150 {isActive 20 + ? 'border-accent-500/50' 21 + : 'border-base-400/30 dark:border-base-500/30'}" 22 + > 23 + <div 24 + class="bg-base-100/80 dark:bg-base-900/80 absolute -bottom-3 left-4 flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium backdrop-blur-sm {isActive 25 + ? 'text-accent-600 dark:text-accent-400' 26 + : 'text-base-500 dark:text-base-400'}" 27 + > 28 + {#if icon} 29 + <span class="[&_svg]:size-3">{@html icon}</span> 30 + {/if} 31 + {name} 32 + </div> 33 + </div> 34 + {/if}
+64
src/lib/sections/TextSection/EditingTextSection.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 4 + import type { EditingSectionContentProps } from '../types'; 5 + import { textAlignClasses, textSizeClasses } from './shared'; 6 + import SectionChrome from '../SectionChrome.svelte'; 7 + 8 + let { section, isActive, onlayoutchange, onactivate, onrefchange }: EditingSectionContentProps = 9 + $props(); 10 + 11 + let d = $derived(section.sectionData); 12 + let containerRef: HTMLDivElement | undefined = $state(); 13 + let hovered = $state(false); 14 + 15 + $effect(() => { 16 + onrefchange(containerRef); 17 + return () => onrefchange(undefined); 18 + }); 19 + 20 + $effect(() => { 21 + const el = containerRef; 22 + if (!el) return; 23 + const enter = () => (hovered = true); 24 + const leave = () => (hovered = false); 25 + const down = () => onactivate(); 26 + el.addEventListener('pointerenter', enter); 27 + el.addEventListener('pointerleave', leave); 28 + el.addEventListener('pointerdown', down); 29 + return () => { 30 + el.removeEventListener('pointerenter', enter); 31 + el.removeEventListener('pointerleave', leave); 32 + el.removeEventListener('pointerdown', down); 33 + }; 34 + }); 35 + 36 + function update(key: string, value: any) { 37 + section.sectionData = { ...d, [key]: value }; 38 + onlayoutchange(); 39 + } 40 + 41 + const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>`; 42 + </script> 43 + 44 + <div 45 + bind:this={containerRef} 46 + class="@container/grid pointer-events-auto relative col-span-3 px-4 py-10" 47 + > 48 + <SectionChrome {isActive} {hovered} name={section.name || 'Text'} {icon} /> 49 + 50 + <div 51 + class={cn( 52 + 'prose dark:prose-invert prose-neutral prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 mx-auto max-w-3xl', 53 + textAlignClasses?.[d.textAlign as string] ?? 'text-left', 54 + textSizeClasses[(d.textSize ?? 1) as number] 55 + )} 56 + > 57 + <MarkdownTextEditor 58 + contentDict={d} 59 + key="text" 60 + placeholder="Write some text..." 61 + onupdate={(text) => update('text', text)} 62 + /> 63 + </div> 64 + </div>
+27
src/lib/sections/TextSection/TextSection.svelte
··· 1 + <script lang="ts"> 2 + import { marked } from 'marked'; 3 + import { sanitize } from '$lib/sanitize'; 4 + import { cn } from '@foxui/core'; 5 + import type { SectionContentProps } from '../types'; 6 + import { textAlignClasses, textSizeClasses } from './shared'; 7 + 8 + let { section }: SectionContentProps = $props(); 9 + 10 + let d = $derived(section.sectionData); 11 + 12 + const renderer = new marked.Renderer(); 13 + renderer.link = ({ href, title, text }) => 14 + `<a target="_blank" href="${href}" title="${title ?? ''}">${text}</a>`; 15 + </script> 16 + 17 + <div class="@container/grid relative col-span-3 px-4 py-10"> 18 + <div 19 + class={cn( 20 + 'prose dark:prose-invert prose-neutral prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 mx-auto max-w-3xl', 21 + textAlignClasses?.[d.textAlign as string] ?? 'text-left', 22 + textSizeClasses[(d.textSize ?? 1) as number] 23 + )} 24 + > 25 + {@html sanitize(marked.parse(d.text ?? '', { renderer }) as string, { ADD_ATTR: ['target'] })} 26 + </div> 27 + </div>
+18
src/lib/sections/TextSection/index.ts
··· 1 + import type { SectionDefinition } from '../types'; 2 + import EditingTextSection from './EditingTextSection.svelte'; 3 + import TextSection from './TextSection.svelte'; 4 + import { defaultTextSectionData } from './shared'; 5 + 6 + export * from './shared'; 7 + 8 + export const TextSectionDefinition: SectionDefinition = { 9 + type: 'text', 10 + contentComponent: TextSection, 11 + editingContentComponent: EditingTextSection, 12 + defaultSectionData: defaultTextSectionData, 13 + addItem: (_item, allItems) => allItems, 14 + deleteItem: (_itemId, allItems) => allItems, 15 + resizeItem: () => {}, 16 + name: 'Text', 17 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>` 18 + };
+15
src/lib/sections/TextSection/shared.ts
··· 1 + export const textAlignClasses: Record<string, string> = { 2 + left: 'text-left', 3 + center: 'text-center', 4 + right: 'text-right' 5 + }; 6 + 7 + export const textSizeClasses = ['prose-sm', 'prose-base', 'prose-lg', 'prose-xl']; 8 + 9 + export function defaultTextSectionData(): Record<string, any> { 10 + return { 11 + text: '## A heading\n\nWrite some **markdown** here. Links like [this one](https://blento.app) work too.', 12 + textAlign: 'left', 13 + textSize: 1 14 + }; 15 + }
+1
src/lib/sections/feature-flag.ts
··· 1 + import { dev } from '$app/environment'; 1 2 import { env } from '$env/dynamic/public'; 2 3 3 4 /**
+10 -1
src/lib/sections/index.ts
··· 1 1 import type { SectionDefinition } from './types'; 2 2 import { GridSectionDefinition } from './GridSection'; 3 3 import { HeroSectionDefinition } from './HeroSection'; 4 + import { TextSectionDefinition } from './TextSection'; 5 + import { RowSectionDefinition } from './RowSection'; 6 + import { GallerySectionDefinition } from './GallerySection'; 4 7 5 - export const AllSectionDefinitions = [GridSectionDefinition, HeroSectionDefinition] as const; 8 + export const AllSectionDefinitions = [ 9 + GridSectionDefinition, 10 + HeroSectionDefinition, 11 + TextSectionDefinition, 12 + RowSectionDefinition, 13 + GallerySectionDefinition 14 + ] as const; 6 15 7 16 export const SectionDefinitionsByType = AllSectionDefinitions.reduce( 8 17 (acc, def) => {
+1
src/lib/sections/types.ts
··· 32 32 editingContentComponent: Component<EditingSectionContentProps>; 33 33 defaultSectionData?: () => Record<string, any>; 34 34 cardFilter?: (cardDef: CardDefinition) => boolean; 35 + allowRotate?: boolean; 35 36 addItem: (item: Item, allItems: Item[], options: AddItemOptions) => Item[]; 36 37 deleteItem: (itemId: string, allItems: Item[], sectionId: string) => Item[]; 37 38 resizeItem: (item: Item, allItems: Item[], w: number, h: number, isMobile: boolean) => void;
+2
src/lib/types.ts
··· 27 27 page?: string; 28 28 29 29 sectionId?: string; 30 + 31 + rotation?: number; 30 32 }; 31 33 32 34 export type PronounSet = {
+282 -220
src/lib/website/EditBar.svelte
··· 30 30 ondeselect, 31 31 ondelete, 32 32 onsetsize, 33 + allowRotate = false, 34 + onrotate, 33 35 showSectionsModal 34 36 }: { 35 37 data: WebsiteData; ··· 54 56 ondeselect?: () => void; 55 57 ondelete?: () => void; 56 58 onsetsize?: (w: number, h: number) => void; 59 + allowRotate?: boolean; 60 + onrotate?: (delta: number) => void; 57 61 showSectionsModal?: () => void; 58 62 } = $props(); 59 63 ··· 130 134 return w >= minW && w <= maxW && h >= minH && h <= maxH; 131 135 } 132 136 137 + function setSize(w: number, h: number) { 138 + if (isMobile) { 139 + onsetsize?.(w * 2, h * 2); 140 + } else { 141 + onsetsize?.(w, h); 142 + } 143 + } 144 + 133 145 const showEditControls = $derived(!!selectedCard); 134 146 </script> 135 147 ··· 144 156 /> 145 157 146 158 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 147 - <Navbar 148 - class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto" 149 - > 159 + <div class="fixed right-0 bottom-2 left-0 z-50 flex flex-col items-center gap-1.5 px-4"> 150 160 {#if showEditControls} 151 - <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 152 - <div class="flex items-center gap-1"> 153 - {#if cardDef?.allowSetColor !== false} 154 - <Popover bind:open={colorPopoverOpen}> 161 + <Navbar 162 + class="dark:bg-base-950 bg-base-100 relative top-auto mx-8 mt-0 h-11 w-[calc(100%-4rem)] max-w-2xl rounded-full px-4" 163 + > 164 + <div class="flex items-center gap-0.5"> 165 + {#if cardDef?.allowSetColor !== false} 166 + <Popover bind:open={colorPopoverOpen}> 167 + {#snippet child({ props })} 168 + <button 169 + {...props} 170 + class={[ 171 + 'cursor-pointer rounded-lg p-1', 172 + selectedColor?.class ?? 'text-base-800 dark:text-base-200' 173 + ]} 174 + > 175 + <svg 176 + xmlns="http://www.w3.org/2000/svg" 177 + viewBox="0 0 24 24" 178 + fill="currentColor" 179 + class="size-4" 180 + > 181 + <path 182 + fill-rule="evenodd" 183 + d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 184 + clip-rule="evenodd" 185 + /> 186 + </svg> 187 + </button> 188 + {/snippet} 189 + <ColorSelect 190 + selected={selectedColor} 191 + colors={colorsChoices} 192 + onselected={(color, previous) => { 193 + if (typeof previous === 'string' || typeof color === 'string') { 194 + return; 195 + } 196 + if (selectedCard) { 197 + selectedCard.color = color.label; 198 + } 199 + }} 200 + class="w-64" 201 + /> 202 + </Popover> 203 + {/if} 204 + 205 + <Popover bind:open={sizePopoverOpen}> 155 206 {#snippet child({ props })} 156 - <button 157 - {...props} 158 - class={[ 159 - 'cursor-pointer rounded-xl p-2', 160 - !selectedCard?.color || 161 - selectedCard.color === 'base' || 162 - selectedCard.color === 'transparent' 163 - ? 'text-base-800 dark:text-base-200' 164 - : 'text-accent-500' 165 - ]} 166 - > 207 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-lg p-1"> 167 208 <svg 168 209 xmlns="http://www.w3.org/2000/svg" 210 + fill="none" 169 211 viewBox="0 0 24 24" 170 - fill="currentColor" 171 - class="size-5" 212 + stroke-width="1.5" 213 + stroke="currentColor" 214 + class="size-4" 172 215 > 173 216 <path 174 - fill-rule="evenodd" 175 - d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 176 - clip-rule="evenodd" 217 + stroke-linecap="round" 218 + stroke-linejoin="round" 219 + d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" 177 220 /> 178 221 </svg> 179 222 </button> 180 223 {/snippet} 181 - <ColorSelect 182 - selected={selectedColor} 183 - colors={colorsChoices} 184 - onselected={(color, previous) => { 185 - if (typeof previous === 'string' || typeof color === 'string') { 186 - return; 187 - } 188 - if (selectedCard) { 189 - selectedCard.color = color.label; 190 - } 191 - }} 192 - class="w-64" 193 - /> 224 + <div class="flex items-center gap-1"> 225 + {#if canSetSize(2, 2)} 226 + <button 227 + onclick={() => setSize(2, 2)} 228 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 229 + > 230 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 231 + <span class="sr-only">set size to 1x1</span> 232 + </button> 233 + {/if} 234 + {#if canSetSize(4, 2)} 235 + <button 236 + onclick={() => setSize(4, 2)} 237 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 238 + > 239 + <div 240 + class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2" 241 + ></div> 242 + <span class="sr-only">set size to 2x1</span> 243 + </button> 244 + {/if} 245 + {#if canSetSize(2, 4)} 246 + <button 247 + onclick={() => setSize(2, 4)} 248 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 249 + > 250 + <div 251 + class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2" 252 + ></div> 253 + <span class="sr-only">set size to 1x2</span> 254 + </button> 255 + {/if} 256 + {#if canSetSize(4, 4)} 257 + <button 258 + onclick={() => setSize(4, 4)} 259 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 260 + > 261 + <div 262 + class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2" 263 + ></div> 264 + <span class="sr-only">set size to 2x2</span> 265 + </button> 266 + {/if} 267 + </div> 194 268 </Popover> 195 - {/if} 269 + 270 + {#if cardDef?.settingsComponent && selectedCard} 271 + <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 272 + {#snippet child({ props })} 273 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-lg p-1"> 274 + <svg 275 + xmlns="http://www.w3.org/2000/svg" 276 + fill="none" 277 + viewBox="0 0 24 24" 278 + stroke-width="2" 279 + stroke="currentColor" 280 + class="size-4" 281 + > 282 + <path 283 + stroke-linecap="round" 284 + stroke-linejoin="round" 285 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 286 + /> 287 + <path 288 + stroke-linecap="round" 289 + stroke-linejoin="round" 290 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 291 + /> 292 + </svg> 293 + </button> 294 + {/snippet} 295 + <cardDef.settingsComponent 296 + bind:item={selectedCard} 297 + onclose={() => { 298 + settingsPopoverOpen = false; 299 + }} 300 + /> 301 + </Popover> 302 + {/if} 196 303 197 - <Popover bind:open={sizePopoverOpen}> 198 - {#snippet child({ props })} 199 - <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 304 + {#if allowRotate} 305 + <button 306 + onclick={() => onrotate?.(-2)} 307 + class="hover:bg-accent-500/10 cursor-pointer rounded-lg p-1" 308 + aria-label="Rotate -2°" 309 + > 200 310 <svg 201 311 xmlns="http://www.w3.org/2000/svg" 202 - fill="none" 203 312 viewBox="0 0 24 24" 204 - stroke-width="1.5" 313 + fill="none" 205 314 stroke="currentColor" 206 - class="size-5" 315 + stroke-width="2" 316 + stroke-linecap="round" 317 + stroke-linejoin="round" 318 + class="size-4" 207 319 > 208 - <path 209 - stroke-linecap="round" 210 - stroke-linejoin="round" 211 - d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" 212 - /> 320 + <path d="M3 7v6h6" /> 321 + <path d="M21 17a9 9 0 0 0-15-6.7L3 13" /> 213 322 </svg> 214 323 </button> 215 - {/snippet} 216 - <div class="flex items-center gap-1"> 217 - {#if canSetSize(2, 2)} 218 - <button 219 - onclick={() => onsetsize?.(4, 4)} 220 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 221 - > 222 - <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 223 - <span class="sr-only">set size to 1x1</span> 224 - </button> 225 - {/if} 226 - {#if canSetSize(4, 2)} 227 - <button 228 - onclick={() => onsetsize?.(8, 4)} 229 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 230 - > 231 - <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 232 - <span class="sr-only">set size to 2x1</span> 233 - </button> 234 - {/if} 235 - {#if canSetSize(2, 4)} 236 - <button 237 - onclick={() => onsetsize?.(4, 8)} 238 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 239 - > 240 - <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 241 - <span class="sr-only">set size to 1x2</span> 242 - </button> 243 - {/if} 244 - {#if canSetSize(4, 4)} 245 - <button 246 - onclick={() => onsetsize?.(8, 8)} 247 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 324 + <button 325 + onclick={() => onrotate?.(2)} 326 + class="hover:bg-accent-500/10 cursor-pointer rounded-lg p-1" 327 + aria-label="Rotate +2°" 328 + > 329 + <svg 330 + xmlns="http://www.w3.org/2000/svg" 331 + viewBox="0 0 24 24" 332 + fill="none" 333 + stroke="currentColor" 334 + stroke-width="2" 335 + stroke-linecap="round" 336 + stroke-linejoin="round" 337 + class="size-4" 248 338 > 249 - <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 250 - <span class="sr-only">set size to 2x2</span> 251 - </button> 252 - {/if} 253 - </div> 254 - </Popover> 255 - 256 - {#if cardDef?.settingsComponent && selectedCard} 257 - <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 258 - {#snippet child({ props })} 259 - <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 260 - <svg 261 - xmlns="http://www.w3.org/2000/svg" 262 - fill="none" 263 - viewBox="0 0 24 24" 264 - stroke-width="2" 265 - stroke="currentColor" 266 - class="size-5" 267 - > 268 - <path 269 - stroke-linecap="round" 270 - stroke-linejoin="round" 271 - d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 272 - /> 273 - <path 274 - stroke-linecap="round" 275 - stroke-linejoin="round" 276 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 277 - /> 278 - </svg> 279 - </button> 280 - {/snippet} 281 - <cardDef.settingsComponent 282 - bind:item={selectedCard} 283 - onclose={() => { 284 - settingsPopoverOpen = false; 285 - }} 286 - /> 287 - </Popover> 288 - {/if} 289 - </div> 290 - <div class="flex items-center gap-1"> 291 - <Button 292 - size="iconLg" 293 - variant="ghost" 294 - class="text-rose-500 backdrop-blur-none" 295 - onclick={() => ondelete?.()} 296 - > 297 - <svg 298 - xmlns="http://www.w3.org/2000/svg" 299 - fill="none" 300 - viewBox="0 0 24 24" 301 - stroke-width="1.5" 302 - stroke="currentColor" 303 - class="size-5" 339 + <path d="M21 7v6h-6" /> 340 + <path d="M3 17a9 9 0 0 1 15-6.7L21 13" /> 341 + </svg> 342 + </button> 343 + {/if} 344 + </div> 345 + <div class="flex items-center gap-0.5"> 346 + <Button 347 + size="icon" 348 + variant="ghost" 349 + class="text-rose-500 backdrop-blur-none" 350 + onclick={() => ondelete?.()} 304 351 > 305 - <path 306 - stroke-linecap="round" 307 - stroke-linejoin="round" 308 - d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 309 - /> 310 - </svg> 311 - </Button> 312 - <Button 313 - size="iconLg" 314 - variant="ghost" 315 - class="backdrop-blur-none" 316 - onclick={() => ondeselect?.()} 317 - > 318 - <svg 319 - xmlns="http://www.w3.org/2000/svg" 320 - fill="none" 321 - viewBox="0 0 24 24" 322 - stroke-width="2" 323 - stroke="currentColor" 324 - class="size-5" 352 + <svg 353 + xmlns="http://www.w3.org/2000/svg" 354 + fill="none" 355 + viewBox="0 0 24 24" 356 + stroke-width="1.5" 357 + stroke="currentColor" 358 + class="size-4" 359 + > 360 + <path 361 + stroke-linecap="round" 362 + stroke-linejoin="round" 363 + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 364 + /> 365 + </svg> 366 + </Button> 367 + <Button 368 + size="icon" 369 + variant="ghost" 370 + class="backdrop-blur-none" 371 + onclick={() => ondeselect?.()} 325 372 > 326 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 327 - </svg> 328 - </Button> 329 - </div> 330 - {:else} 373 + <svg 374 + xmlns="http://www.w3.org/2000/svg" 375 + fill="none" 376 + viewBox="0 0 24 24" 377 + stroke-width="2" 378 + stroke="currentColor" 379 + class="size-4" 380 + > 381 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 382 + </svg> 383 + </Button> 384 + </div> 385 + </Navbar> 386 + {/if} 387 + 388 + <Navbar 389 + class="dark:bg-base-950 bg-base-100 relative top-auto mt-0 h-13 w-full max-w-3xl rounded-full px-4" 390 + > 331 391 <div class="flex items-center gap-2"> 332 - <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 392 + <Button size="icon" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 333 393 <svg 334 394 xmlns="http://www.w3.org/2000/svg" 335 395 fill="none" 336 396 viewBox="0 0 24 24" 337 397 stroke-width="1.5" 338 398 stroke="currentColor" 399 + class="size-4" 339 400 > 340 401 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 341 402 </svg> 342 403 </Button> 343 404 </div> 344 - {/if} 345 - <div class={['flex items-center gap-2', showEditControls ? 'hidden' : '']}> 346 - {#if showSectionsModal} 347 - <Button 348 - size="iconLg" 349 - variant="ghost" 350 - class="backdrop-blur-none" 351 - onclick={showSectionsModal} 405 + <div class="flex items-center gap-2"> 406 + {#if showSectionsModal} 407 + <Button 408 + size="icon" 409 + variant="ghost" 410 + class="backdrop-blur-none" 411 + onclick={showSectionsModal} 412 + > 413 + <svg 414 + xmlns="http://www.w3.org/2000/svg" 415 + viewBox="0 0 24 24" 416 + fill="none" 417 + stroke="currentColor" 418 + stroke-width="2" 419 + stroke-linecap="round" 420 + stroke-linejoin="round" 421 + class="size-4" 422 + > 423 + <rect x="3" y="3" width="18" height="18" rx="2" /> 424 + <path d="M3 9h18" /> 425 + <path d="M3 15h18" /> 426 + </svg> 427 + </Button> 428 + {/if} 429 + <Toggle 430 + class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 431 + bind:pressed={showingMobileView} 352 432 > 353 433 <svg 354 434 xmlns="http://www.w3.org/2000/svg" ··· 360 440 stroke-linejoin="round" 361 441 class="size-5" 362 442 > 363 - <rect x="3" y="3" width="18" height="18" rx="2" /> 364 - <path d="M3 9h18" /> 365 - <path d="M3 15h18" /> 443 + <rect width="14" height="20" x="5" y="2" rx="2" ry="2" /> 444 + <path d="M12 18h.01" /> 366 445 </svg> 367 - </Button> 368 - {/if} 369 - <Toggle 370 - class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 371 - bind:pressed={showingMobileView} 372 - > 373 - <svg 374 - xmlns="http://www.w3.org/2000/svg" 375 - viewBox="0 0 24 24" 376 - fill="none" 377 - stroke="currentColor" 378 - stroke-width="2" 379 - stroke-linecap="round" 380 - stroke-linejoin="round" 381 - class="size-7" 382 - > 383 - <rect width="14" height="20" x="5" y="2" rx="2" ry="2" /> 384 - <path d="M12 18h.01" /> 385 - </svg> 386 - </Toggle> 387 - {#if hasUnsavedChanges} 388 - <Button 389 - disabled={isSaving} 390 - onclick={async () => { 391 - save(); 392 - }}>{isSaving ? 'Saving...' : 'Save'}</Button 393 - > 394 - {:else} 395 - <Button onclick={copyShareLink}> 396 - <svg 397 - xmlns="http://www.w3.org/2000/svg" 398 - fill="none" 399 - viewBox="0 0 24 24" 400 - stroke-width="2" 401 - stroke="currentColor" 402 - class="size-5" 446 + </Toggle> 447 + {#if hasUnsavedChanges} 448 + <Button 449 + size="sm" 450 + disabled={isSaving} 451 + onclick={async () => { 452 + save(); 453 + }}>{isSaving ? 'Saving...' : 'Save'}</Button 403 454 > 404 - <path 405 - stroke-linecap="round" 406 - stroke-linejoin="round" 407 - d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" 408 - /> 409 - </svg> 410 - Share 411 - </Button> 412 - {/if} 413 - </div> 414 - </Navbar> 455 + {:else} 456 + <Button size="sm" onclick={copyShareLink}> 457 + <svg 458 + xmlns="http://www.w3.org/2000/svg" 459 + fill="none" 460 + viewBox="0 0 24 24" 461 + stroke-width="2" 462 + stroke="currentColor" 463 + class="size-4" 464 + > 465 + <path 466 + stroke-linecap="round" 467 + stroke-linejoin="round" 468 + d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" 469 + /> 470 + </svg> 471 + Share 472 + </Button> 473 + {/if} 474 + </div> 475 + </Navbar> 476 + </div> 415 477 {/if}
+13
src/lib/website/EditableWebsite.svelte
··· 570 570 onLayoutChanged(); 571 571 } 572 572 }} 573 + allowRotate={(() => { 574 + if (!selectedCard) return false; 575 + const section = sections.find((s) => s.id === selectedCard.sectionId); 576 + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; 577 + const cardDef = CardDefinitionsByType[selectedCard.cardType]; 578 + return !!def?.allowRotate && cardDef?.allowRotate !== false; 579 + })()} 580 + onrotate={(delta: number) => { 581 + if (selectedCard) { 582 + selectedCard.rotation = (selectedCard.rotation ?? 0) + delta; 583 + onLayoutChanged(); 584 + } 585 + }} 573 586 showSectionsModal={SECTIONS_EDITING_ENABLED 574 587 ? () => { 575 588 showSectionsModal = true;
+4 -10
src/lib/website/EmptyState.svelte
··· 1 1 <script lang="ts"> 2 - import BaseCard from '$lib/cards/_base/BaseCard/BaseCard.svelte'; 2 + import GridBaseCard from '$lib/cards/_base/BaseCard/GridBaseCard.svelte'; 3 3 import Card from '$lib/cards/_base/Card/Card.svelte'; 4 4 import type { Item, WebsiteData } from '$lib/types'; 5 - import { text } from '@sveltejs/kit'; 6 5 7 6 let { data }: { data: WebsiteData } = $props(); 8 7 ··· 89 88 }); 90 89 91 90 let maxHeight = $derived(cards.reduce((max, item) => Math.max(max, item.y + item.h), 0)); 92 - 93 - let maxMobileHeight = $derived( 94 - cards.reduce((max, item) => Math.max(max, item.mobileY + item.mobileH), 0) 95 - ); 96 91 </script> 97 92 98 93 {#each cards as item (item.id)} 99 - <BaseCard {item}> 94 + <GridBaseCard {item}> 100 95 <Card {item} /> 101 - </BaseCard> 96 + </GridBaseCard> 102 97 {/each} 103 98 104 99 <!-- Spacer for grid height --> 105 - <div class="hidden @[42rem]/grid:block" style="height: {(maxHeight / 8) * 100}cqw;"></div> 106 - <div class="@[42rem]/grid:hidden" style="height: {(maxMobileHeight / 4) * 100}cqw;"></div> 100 + <div style="height: {(maxHeight / 8) * 100}cqw;"></div>
+5 -2
src/lib/website/file-processing.ts
··· 55 55 56 56 const item = createEmptyCard(page, sectionId); 57 57 item.cardType = isGif ? 'gif' : 'image'; 58 - item.cardData = { image: { blob: file, objectUrl } }; 58 + const { width, height } = await getImageDimensions(objectUrl); 59 + item.cardData = { 60 + image: { blob: file, objectUrl }, 61 + aspectRatio: { width, height } 62 + }; 59 63 60 - const { width, height } = await getImageDimensions(objectUrl); 61 64 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 62 65 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 63 66 item.w = dw;
+63 -14
src/lib/website/load.ts
··· 236 236 sectionRecords = contrailData.sections; 237 237 pageRecords = contrailData.pages; 238 238 239 - const extracted = extractProfileData(did, contrailData.profiles); 240 - profile = extracted.profile; 241 - publication = extracted.publication; 242 - pronounsRecord = extracted.pronounsRecord; 239 + const hasBskyProfile = contrailData.profiles.some( 240 + (p) => p.did === did && p.collection === 'app.bsky.actor.profile' 241 + ); 242 + 243 + if (hasBskyProfile) { 244 + const extracted = extractProfileData(did, contrailData.profiles); 245 + profile = extracted.profile; 246 + publication = extracted.publication; 247 + pronounsRecord = extracted.pronounsRecord; 248 + } else { 249 + // Contrail didn't return profile data (e.g. no card records) — fetch from PDS 250 + const [prof, mainPub, pronouns] = await Promise.all([ 251 + getDetailedProfile({ did }), 252 + getSelfPublicationFromPDS(did), 253 + getPronounsFromPDS(did) 254 + ]); 255 + profile = prof; 256 + publication = mainPub?.value as WebsiteData['publication'] | undefined; 257 + pronounsRecord = pronouns; 258 + 259 + // Still extract publication/pronouns from contrail if available 260 + const extracted = extractProfileData(did, contrailData.profiles); 261 + if (!publication) publication = extracted.publication; 262 + if (!pronounsRecord) pronounsRecord = extracted.pronounsRecord; 263 + } 243 264 } else { 244 265 // Fallback: no D1 available (e.g. vite dev) — use PDS directly 245 266 const [cardRecords, pageRecs, sectionRecs, mainPub, prof, pronouns] = await Promise.all([ ··· 330 351 cardValue = card; 331 352 332 353 if (profiles) { 333 - const extracted = extractProfileData(did, profiles); 334 - profile = extracted.profile; 335 - publication = extracted.publication; 336 - pronounsRecord = extracted.pronounsRecord; 354 + const hasBskyProfile = profiles.some( 355 + (p) => p.did === did && p.collection === 'app.bsky.actor.profile' 356 + ); 357 + if (hasBskyProfile) { 358 + const extracted = extractProfileData(did, profiles); 359 + profile = extracted.profile; 360 + publication = extracted.publication; 361 + pronounsRecord = extracted.pronounsRecord; 362 + } else { 363 + // Contrail didn't return bsky profile — fetch from PDS 364 + const [prof, mainPub, pronouns] = await Promise.all([ 365 + getDetailedProfile({ did }), 366 + getSelfPublicationFromPDS(did), 367 + getPronounsFromPDS(did) 368 + ]); 369 + profile = prof; 370 + publication = mainPub?.value as WebsiteData['publication'] | undefined; 371 + pronounsRecord = pronouns; 372 + 373 + const extracted = extractProfileData(did, profiles); 374 + if (!publication) publication = extracted.publication; 375 + if (!pronounsRecord) pronounsRecord = extracted.pronounsRecord; 376 + } 337 377 } 338 378 } 339 379 ··· 419 459 if (db) { 420 460 const profiles = await loadProfilesFromContrail(handle, db); 421 461 if (profiles) { 422 - const extracted = extractProfileData(did, profiles); 423 - profile = extracted.profile; 424 - publication = extracted.publication; 425 - pronounsRecord = extracted.pronounsRecord; 462 + const hasBskyProfile = profiles.some( 463 + (p) => p.did === did && p.collection === 'app.bsky.actor.profile' 464 + ); 465 + if (hasBskyProfile) { 466 + const extracted = extractProfileData(did, profiles); 467 + profile = extracted.profile; 468 + publication = extracted.publication; 469 + pronounsRecord = extracted.pronounsRecord; 470 + } else { 471 + const extracted = extractProfileData(did, profiles); 472 + publication = extracted.publication; 473 + pronounsRecord = extracted.pronounsRecord; 474 + } 426 475 } 427 476 } 428 477 ··· 433 482 getPronounsFromPDS(did) 434 483 ]); 435 484 profile = prof; 436 - publication = pubRecord?.value as WebsiteData['publication'] | undefined; 437 - pronounsRecord = pronouns; 485 + if (!publication) publication = pubRecord?.value as WebsiteData['publication'] | undefined; 486 + if (!pronounsRecord) pronounsRecord = pronouns; 438 487 } 439 488 440 489 if (!profile) throw error(404);
+2 -2
wrangler.jsonc
··· 60 60 { 61 61 "binding": "DB", 62 62 "database_name": "blento", 63 - "database_id": "922639e7-6321-42c8-a4bd-cdf48428fdac" 64 - // "remote": true 63 + "database_id": "922639e7-6321-42c8-a4bd-cdf48428fdac", 64 + "remote": true 65 65 } 66 66 ], 67 67 "triggers": {