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

Configure Feed

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

sections

Florian 8608a8f4 af1efca3

+920 -403
+1
lex.config.js
··· 13 13 nsids: [ 14 14 'app.blento.card', 15 15 'app.blento.page', 16 + 'app.blento.section', 16 17 'app.bsky.actor.profile', 17 18 'site.standard.publication' 18 19 ]
+1
lexicons/app/blento/card.json
··· 22 22 "cardData": { "type": "unknown" }, 23 23 "color": { "type": "string" }, 24 24 "page": { "type": "string" }, 25 + "sectionId": { "type": "string" }, 25 26 "updatedAt": { "type": "string", "format": "datetime" }, 26 27 "version": { "type": "integer" } 27 28 }
+24
lexicons/app/blento/section.json
··· 1 + { 2 + "$type": "com.atproto.lexicon.schema", 3 + "lexicon": 1, 4 + "id": "app.blento.section", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["sectionType", "page", "index"], 12 + "properties": { 13 + "sectionType": { "type": "string" }, 14 + "page": { "type": "string" }, 15 + "index": { "type": "integer" }, 16 + "sectionData": { "type": "unknown" }, 17 + "name": { "type": "string" }, 18 + "updatedAt": { "type": "string", "format": "datetime" }, 19 + "version": { "type": "integer" } 20 + } 21 + } 22 + } 23 + } 24 + }
+1
src/lib/atproto/settings.ts
··· 6 6 export const collections = [ 7 7 'app.blento.card', 8 8 'app.blento.page', 9 + 'app.blento.section', 9 10 'app.blento.settings', 10 11 'app.blento.comment', 11 12 'app.blento.guestbook.entry',
+6
src/lib/contrail/config.ts
··· 11 11 }, 12 12 'app.blento.page': { 13 13 queryable: {} 14 + }, 15 + 'app.blento.section': { 16 + queryable: { 17 + page: {}, 18 + sectionType: {} 19 + } 14 20 } 15 21 }, 16 22 profiles: [
+30 -3
src/lib/helper.ts
··· 188 188 data: WebsiteData, 189 189 currentItems: Item[], 190 190 originalCards: Item[], 191 - originalPublication: string 191 + originalPublication: string, 192 + originalSections?: import('$lib/types').SectionRecord[] 192 193 ) { 193 194 const promises = []; 195 + 196 + // Save sections 197 + for (const section of data.sections) { 198 + section.updatedAt = new Date().toISOString(); 199 + section.version = 1; 200 + const record = JSON.parse(JSON.stringify(section)); 201 + const rkey = record.id; 202 + delete record.id; 203 + promises.push( 204 + putRecord({ 205 + collection: 'app.blento.section', 206 + rkey, 207 + record 208 + }) 209 + ); 210 + } 211 + 212 + // Delete removed sections 213 + if (originalSections) { 214 + for (const original of originalSections) { 215 + if (!data.sections.find((s) => s.id === original.id)) { 216 + promises.push(deleteRecord({ collection: 'app.blento.section', rkey: original.id })); 217 + } 218 + } 219 + } 194 220 195 221 // Save all current cards. We don't diff against originals because the 196 222 // server-side load can modify cards (e.g. fixing overlaps), so the ··· 301 327 fetch(`/${data.did}/og-new.png`, { method: 'DELETE' }).catch(() => {}); 302 328 } 303 329 304 - export function createEmptyCard(page: string) { 330 + export function createEmptyCard(page: string, sectionId?: string) { 305 331 return { 306 332 id: TID.now(), 307 333 x: 0, ··· 314 340 mobileY: 0, 315 341 cardType: '', 316 342 cardData: {}, 317 - page 343 + page, 344 + sectionId 318 345 } as Item; 319 346 } 320 347
+150
src/lib/sections/GridSection/EditingGridSection.svelte
··· 1 + <script lang="ts"> 2 + import type { EditingSectionContentProps } from '../types'; 3 + import { EditableGrid, fixCollisions, compactItems, setPositionOfNewItem } from '$lib/layout'; 4 + import BaseEditingCard from '$lib/cards/_base/BaseCard/BaseEditingCard.svelte'; 5 + import EditingCard from '$lib/cards/_base/Card/EditingCard.svelte'; 6 + import { SectionDefinitionsByType } from '$lib/sections'; 7 + import { positionItemAtGridPos } from './add-item'; 8 + 9 + let { 10 + section, 11 + items = $bindable(), 12 + isMobile, 13 + selectedCardId, 14 + isCoarse, 15 + isActive, 16 + onlayoutchange, 17 + ondeselect, 18 + onadditem, 19 + oncreatefilecards, 20 + onactivate, 21 + onrefchange 22 + }: EditingSectionContentProps = $props(); 23 + 24 + let gridRef: HTMLDivElement | undefined = $state(); 25 + 26 + let sectionItems = $derived(items.filter((i) => i.sectionId === section.id)); 27 + 28 + let hovered = $state(false); 29 + const def = $derived(SectionDefinitionsByType[section.sectionType]); 30 + 31 + $effect(() => { 32 + onrefchange(gridRef); 33 + return () => onrefchange(undefined); 34 + }); 35 + 36 + $effect(() => { 37 + const el = gridRef; 38 + if (!el) return; 39 + 40 + const enter = () => (hovered = true); 41 + const leave = () => (hovered = false); 42 + const down = () => onactivate(); 43 + 44 + el.addEventListener('pointerenter', enter); 45 + el.addEventListener('pointerleave', leave); 46 + el.addEventListener('pointerdown', down); 47 + 48 + return () => { 49 + el.removeEventListener('pointerenter', enter); 50 + el.removeEventListener('pointerleave', leave); 51 + el.removeEventListener('pointerdown', down); 52 + }; 53 + }); 54 + 55 + async function handleFileDrop(files: File[], gridX: number, gridY: number) { 56 + const cards = await oncreatefilecards(files); 57 + for (let i = 0; i < cards.length; i++) { 58 + const card = cards[i]; 59 + card.sectionId = section.id; 60 + if (i === 0) { 61 + positionItemAtGridPos(card, gridX, gridY, isMobile); 62 + } else { 63 + const currentSectionItems = items.filter((it) => it.sectionId === section.id); 64 + setPositionOfNewItem(card, currentSectionItems); 65 + } 66 + 67 + items = [...items, card]; 68 + const updatedSectionItems = items.filter((it) => it.sectionId === section.id); 69 + fixCollisions(updatedSectionItems, card, isMobile); 70 + fixCollisions(updatedSectionItems, card, !isMobile); 71 + } 72 + onlayoutchange(); 73 + } 74 + </script> 75 + 76 + <EditableGrid 77 + items={sectionItems} 78 + bind:ref={gridRef} 79 + {isMobile} 80 + {selectedCardId} 81 + {isCoarse} 82 + {onlayoutchange} 83 + {ondeselect} 84 + onfiledrop={handleFileDrop} 85 + > 86 + {#if hovered || isActive} 87 + <div 88 + class="pointer-events-none absolute inset-0 z-30 rounded-3xl border-2 border-dashed transition-colors duration-150 {isActive 89 + ? 'border-accent-500/50' 90 + : 'border-base-400/30 dark:border-base-500/30'}" 91 + > 92 + <div 93 + 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 94 + ? 'text-accent-600 dark:text-accent-400' 95 + : 'text-base-500 dark:text-base-400'}" 96 + > 97 + {#if def?.icon} 98 + <span class="[&_svg]:size-3">{@html def.icon}</span> 99 + {/if} 100 + {section.name || def?.name || section.sectionType} 101 + </div> 102 + </div> 103 + {/if} 104 + 105 + {#if sectionItems.length === 0} 106 + <div 107 + class="border-base-300/50 dark:border-base-700/50 pointer-events-auto relative flex min-h-32 items-center justify-center rounded-3xl border-2 border-dashed" 108 + > 109 + <p class="text-base-400 dark:text-base-500 text-sm"> 110 + Click the + button to add cards to this section 111 + </p> 112 + </div> 113 + {/if} 114 + 115 + {#each sectionItems as item (item.id)} 116 + {@const idx = items.indexOf(item)} 117 + <BaseEditingCard 118 + bind:item={items[idx]} 119 + ondelete={() => { 120 + items = items.filter((it) => it !== item); 121 + compactItems( 122 + items.filter((i) => i.sectionId === section.id), 123 + false 124 + ); 125 + compactItems( 126 + items.filter((i) => i.sectionId === section.id), 127 + true 128 + ); 129 + onlayoutchange(); 130 + }} 131 + onsetsize={(newW, newH) => { 132 + if (isMobile) { 133 + item.mobileW = newW; 134 + item.mobileH = newH; 135 + } else { 136 + item.w = newW; 137 + item.h = newH; 138 + } 139 + fixCollisions( 140 + items.filter((i) => i.sectionId === section.id), 141 + item, 142 + isMobile 143 + ); 144 + onlayoutchange(); 145 + }} 146 + > 147 + <EditingCard bind:item={items[idx]} /> 148 + </BaseEditingCard> 149 + {/each} 150 + </EditableGrid>
+24
src/lib/sections/GridSection/GridSection.svelte
··· 1 + <script lang="ts"> 2 + import type { SectionContentProps } from '../types'; 3 + import BaseCard from '$lib/cards/_base/BaseCard/BaseCard.svelte'; 4 + import Card from '$lib/cards/_base/Card/Card.svelte'; 5 + import { sortItems } from '$lib/helper'; 6 + 7 + let { section, items, isMobile }: SectionContentProps = $props(); 8 + 9 + let maxHeight = $derived( 10 + items.reduce( 11 + (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), 12 + 0 13 + ) 14 + ); 15 + </script> 16 + 17 + <div class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 18 + {#each items.toSorted(sortItems) as item (item.id)} 19 + <BaseCard {item}> 20 + <Card {item} /> 21 + </BaseCard> 22 + {/each} 23 + <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 24 + </div>
+45
src/lib/sections/GridSection/add-item.ts
··· 1 + import { COLUMNS } from '$lib'; 2 + import { 3 + setPositionOfNewItem, 4 + fixCollisions, 5 + compactItems, 6 + getViewportCenterGridY 7 + } from '$lib/layout'; 8 + import type { Item } from '$lib/types'; 9 + import type { AddItemOptions } from '../types'; 10 + 11 + export function addItemToGridSection( 12 + item: Item, 13 + allItems: Item[], 14 + options: AddItemOptions 15 + ): Item[] { 16 + const sectionItems = allItems.filter((i) => i.sectionId === item.sectionId); 17 + const viewportCenter = options.gridRef 18 + ? getViewportCenterGridY(options.gridRef, options.isMobile) 19 + : undefined; 20 + setPositionOfNewItem(item, sectionItems, viewportCenter); 21 + 22 + const newItems = [...allItems, item]; 23 + const updatedSectionItems = newItems.filter((i) => i.sectionId === item.sectionId); 24 + 25 + fixCollisions(updatedSectionItems, item, false, true); 26 + fixCollisions(updatedSectionItems, item, true, true); 27 + compactItems(updatedSectionItems, false); 28 + compactItems(updatedSectionItems, true); 29 + 30 + return newItems; 31 + } 32 + 33 + export function positionItemAtGridPos(item: Item, gridX: number, gridY: number, isMobile: boolean) { 34 + if (isMobile) { 35 + item.mobileX = gridX; 36 + item.mobileY = gridY; 37 + item.x = Math.floor((COLUMNS - item.w) / 2); 38 + item.y = Math.max(0, Math.round(gridY / 2)); 39 + } else { 40 + item.x = gridX; 41 + item.y = gridY; 42 + item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 43 + item.mobileY = Math.max(0, Math.round(gridY * 2)); 44 + } 45 + }
+37
src/lib/sections/GridSection/index.ts
··· 1 + import { fixCollisions, compactItems } from '$lib/layout'; 2 + import type { Item } from '$lib/types'; 3 + import type { SectionDefinition } from '../types'; 4 + import { addItemToGridSection } from './add-item'; 5 + import EditingGridSection from './EditingGridSection.svelte'; 6 + import GridSection from './GridSection.svelte'; 7 + 8 + function getSectionItems(allItems: Item[], sectionId: string) { 9 + return allItems.filter((i) => i.sectionId === sectionId); 10 + } 11 + 12 + export const GridSectionDefinition: SectionDefinition = { 13 + type: 'grid', 14 + contentComponent: GridSection, 15 + editingContentComponent: EditingGridSection, 16 + addItem: (item, allItems, options) => addItemToGridSection(item, allItems, options), 17 + deleteItem: (itemId, allItems, sectionId) => { 18 + const newItems = allItems.filter((i) => i.id !== itemId); 19 + const sectionItems = getSectionItems(newItems, sectionId); 20 + compactItems(sectionItems, false); 21 + compactItems(sectionItems, true); 22 + return newItems; 23 + }, 24 + resizeItem: (item, allItems, w, h, isMobile) => { 25 + if (isMobile) { 26 + item.mobileW = w; 27 + item.mobileH = h; 28 + } else { 29 + item.w = w; 30 + item.h = h; 31 + } 32 + const sectionItems = getSectionItems(allItems, item.sectionId!); 33 + fixCollisions(sectionItems, item, isMobile); 34 + }, 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>` 37 + };
+12
src/lib/sections/index.ts
··· 1 + import type { SectionDefinition } from './types'; 2 + import { GridSectionDefinition } from './GridSection'; 3 + 4 + export const AllSectionDefinitions = [GridSectionDefinition] as const; 5 + 6 + export const SectionDefinitionsByType = AllSectionDefinitions.reduce( 7 + (acc, def) => { 8 + acc[def.type] = def; 9 + return acc; 10 + }, 11 + {} as Record<string, SectionDefinition> 12 + );
+27
src/lib/sections/migrate.ts
··· 1 + import * as TID from '@atcute/tid'; 2 + import type { Item, SectionRecord } from '$lib/types'; 3 + 4 + export function ensureSections( 5 + storedSections: SectionRecord[], 6 + cards: Item[], 7 + page: string 8 + ): { sections: SectionRecord[]; cards: Item[] } { 9 + if (storedSections.length > 0) { 10 + const firstGridId = 11 + storedSections.find((s) => s.sectionType === 'grid')?.id ?? storedSections[0].id; 12 + const fixedCards = cards.map((c) => (c.sectionId ? c : { ...c, sectionId: firstGridId })); 13 + return { sections: storedSections, cards: fixedCards }; 14 + } 15 + 16 + const newId = TID.now(); 17 + const synthesized: SectionRecord = { 18 + id: newId, 19 + sectionType: 'grid', 20 + page, 21 + index: 0, 22 + sectionData: {}, 23 + version: 1 24 + }; 25 + const fixedCards = cards.map((c) => ({ ...c, sectionId: c.sectionId ?? newId })); 26 + return { sections: [synthesized], cards: fixedCards }; 27 + }
+36
src/lib/sections/types.ts
··· 1 + import type { Component } from 'svelte'; 2 + import type { Item, SectionRecord } from '$lib/types'; 3 + 4 + export type SectionContentProps = { 5 + section: SectionRecord; 6 + items: Item[]; 7 + isMobile: boolean; 8 + }; 9 + 10 + export type EditingSectionContentProps = SectionContentProps & { 11 + selectedCardId: string | null; 12 + isCoarse: boolean; 13 + isActive: boolean; 14 + onlayoutchange: () => void; 15 + ondeselect: () => void; 16 + onadditem: (item: Item) => void; 17 + oncreatefilecards: (files: File[]) => Promise<Item[]>; 18 + onactivate: () => void; 19 + onrefchange: (el: HTMLDivElement | undefined) => void; 20 + }; 21 + 22 + export type AddItemOptions = { 23 + gridRef?: HTMLDivElement; 24 + isMobile: boolean; 25 + }; 26 + 27 + export type SectionDefinition = { 28 + type: string; 29 + contentComponent: Component<SectionContentProps>; 30 + editingContentComponent: Component<EditingSectionContentProps>; 31 + addItem: (item: Item, allItems: Item[], options: AddItemOptions) => Item[]; 32 + deleteItem: (itemId: string, allItems: Item[], sectionId: string) => Item[]; 33 + resizeItem: (item: Item, allItems: Item[], w: number, h: number, isMobile: boolean) => void; 34 + name: string; 35 + icon?: string; 36 + };
+15
src/lib/types.ts
··· 25 25 version?: number; 26 26 27 27 page?: string; 28 + 29 + sectionId?: string; 28 30 }; 29 31 30 32 export type PronounSet = { ··· 38 40 createdAt?: string; 39 41 updatedAt?: string; 40 42 }; 43 + }; 44 + 45 + export type SectionRecord = { 46 + id: string; 47 + sectionType: string; 48 + page: string; 49 + index: number; 50 + sectionData: Record<string, any>; 51 + name?: string; 52 + updatedAt?: string; 53 + version?: number; 41 54 }; 42 55 43 56 export type WebsiteData = { ··· 79 92 profile: AppBskyActorDefs.ProfileViewDetailed; 80 93 pronouns?: string; 81 94 pronounsRecord?: PronounsRecord; 95 + 96 + sections: SectionRecord[]; 82 97 83 98 additionalData: Record<string, unknown>; 84 99 updatedAt: number;
+59
src/lib/website/AddSectionButton.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Popover } from '@foxui/core'; 3 + import { AllSectionDefinitions } from '$lib/sections'; 4 + 5 + let { 6 + onadd 7 + }: { 8 + onadd: (sectionType: string) => void; 9 + } = $props(); 10 + 11 + let popoverOpen = $state(false); 12 + </script> 13 + 14 + <div class="pointer-events-auto relative col-span-3 flex w-full items-center justify-center py-1"> 15 + <Popover bind:open={popoverOpen}> 16 + {#snippet child({ props })} 17 + <button 18 + {...props} 19 + type="button" 20 + class="border-base-300 dark:border-base-700 hover:border-accent-500 hover:bg-accent-500/10 text-base-400 dark:text-base-500 hover:text-accent-600 dark:hover:text-accent-400 flex cursor-pointer items-center gap-1 rounded-full border bg-white/50 px-3 py-1 text-xs font-medium opacity-0 backdrop-blur-sm transition-all duration-150 group-hover/wrapper:opacity-60 hover:opacity-100 focus:opacity-100 dark:bg-black/30" 21 + > 22 + <svg 23 + xmlns="http://www.w3.org/2000/svg" 24 + viewBox="0 0 24 24" 25 + fill="none" 26 + stroke="currentColor" 27 + stroke-width="2" 28 + stroke-linecap="round" 29 + stroke-linejoin="round" 30 + class="size-3" 31 + > 32 + <path d="M12 5v14" /> 33 + <path d="M5 12h14" /> 34 + </svg> 35 + Add section 36 + </button> 37 + {/snippet} 38 + <div class="flex flex-col gap-1 p-1"> 39 + {#each AllSectionDefinitions as def (def.type)} 40 + <Button 41 + variant="ghost" 42 + size="sm" 43 + class="justify-start gap-2 text-sm" 44 + onclick={() => { 45 + onadd(def.type); 46 + popoverOpen = false; 47 + }} 48 + > 49 + {#if def.icon} 50 + <span class="text-base-500 dark:text-base-400"> 51 + {@html def.icon} 52 + </span> 53 + {/if} 54 + {def.name} 55 + </Button> 56 + {/each} 57 + </div> 58 + </Popover> 59 + </div>
+24 -19
src/lib/website/EditBar.svelte
··· 17 17 18 18 save, 19 19 20 - handleImageInputChange, 21 - handleVideoInputChange, 20 + handleFileInputChange, 22 21 23 22 newCard, 24 23 addLink, ··· 30 29 isCoarse = false, 31 30 ondeselect, 32 31 ondelete, 33 - onsetsize 32 + onsetsize, 33 + showSectionsModal 34 34 }: { 35 35 data: WebsiteData; 36 36 ··· 41 41 42 42 save: () => Promise<void>; 43 43 44 - handleImageInputChange: (evt: Event) => void; 45 - handleVideoInputChange: (evt: Event) => void; 44 + handleFileInputChange: (evt: Event) => void; 46 45 47 46 newCard: (type?: string, cardData?: any) => void; 48 47 addLink: (url: string) => void; ··· 55 54 ondeselect?: () => void; 56 55 ondelete?: () => void; 57 56 onsetsize?: (w: number, h: number) => void; 57 + showSectionsModal: () => void; 58 58 } = $props(); 59 59 60 60 let linkPopoverOpen = $state(false); 61 61 let imageInputRef: HTMLInputElement | undefined = $state(); 62 - let videoInputRef: HTMLInputElement | undefined = $state(); 63 62 64 63 function getShareUrl() { 65 64 const base = typeof window !== 'undefined' ? window.location.origin : ''; ··· 136 135 137 136 <input 138 137 type="file" 139 - accept="image/*" 140 - onchange={handleImageInputChange} 138 + accept="image/*,video/*" 139 + onchange={handleFileInputChange} 141 140 class="hidden" 142 - id="image-input" 141 + id="file-input" 143 142 multiple 144 143 bind:this={imageInputRef} 145 - /> 146 - 147 - <input 148 - type="file" 149 - accept="video/*" 150 - onchange={handleVideoInputChange} 151 - class="hidden" 152 - id="video-input" 153 - multiple 154 - bind:this={videoInputRef} 155 144 /> 156 145 157 146 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} ··· 354 343 </div> 355 344 {/if} 356 345 <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 346 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showSectionsModal}> 347 + <svg 348 + xmlns="http://www.w3.org/2000/svg" 349 + viewBox="0 0 24 24" 350 + fill="none" 351 + stroke="currentColor" 352 + stroke-width="2" 353 + stroke-linecap="round" 354 + stroke-linejoin="round" 355 + class="size-5" 356 + > 357 + <rect x="3" y="3" width="18" height="18" rx="2" /> 358 + <path d="M3 9h18" /> 359 + <path d="M3 15h18" /> 360 + </svg> 361 + </Button> 357 362 <Toggle 358 363 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 359 364 bind:pressed={showingMobileView}
+158 -356
src/lib/website/EditableWebsite.svelte
··· 1 1 <script lang="ts"> 2 2 import { Button, Modal, toast, Toaster } from '@foxui/core'; 3 - import { COLUMNS } from '$lib'; 4 3 import { 5 4 checkAndUploadImage, 6 5 createEmptyCard, ··· 16 15 import EditableProfile from './EditableProfile.svelte'; 17 16 import type { Item, WebsiteData } from '../types'; 18 17 import { innerWidth } from 'svelte/reactivity/window'; 19 - import EditingCard from '../cards/_base/Card/EditingCard.svelte'; 20 18 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 21 19 import { tick, type Component } from 'svelte'; 22 20 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 23 - import { dev } from '$app/environment'; 24 21 import { page } from '$app/state'; 25 22 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 26 - import BaseEditingCard from '../cards/_base/BaseCard/BaseEditingCard.svelte'; 27 23 import Context from './Context.svelte'; 28 24 import Head from './Head.svelte'; 29 25 import Account from './Account.svelte'; 30 26 import EditBar from './EditBar.svelte'; 31 27 import SaveModal from './SaveModal.svelte'; 32 28 import FloatingEditButton from './FloatingEditButton.svelte'; 33 - import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 29 + import { user } from '$lib/atproto'; 34 30 import * as TID from '@atcute/tid'; 35 31 import { launchConfetti } from '@foxui/visual'; 36 32 import Controls from './Controls.svelte'; 33 + import SectionsModal from './SectionsModal.svelte'; 34 + import AddSectionButton from './AddSectionButton.svelte'; 35 + import { createImageCard, createVideoCard } from './file-processing'; 37 36 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 38 37 import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte'; 39 38 import { SvelteMap } from 'svelte/reactivity'; 40 - import { 41 - fixCollisions, 42 - compactItems, 43 - fixAllCollisions, 44 - setPositionOfNewItem, 45 - shouldMirror, 46 - mirrorLayout, 47 - getViewportCenterGridY, 48 - EditableGrid 49 - } from '$lib/layout'; 39 + import { shouldMirror, mirrorLayout } from '$lib/layout'; 40 + import { SectionDefinitionsByType } from '$lib/sections'; 41 + import type { SectionRecord } from '$lib/types'; 50 42 51 43 let { 52 44 data ··· 65 57 return `${origin}/${actor}/og-new.png`; 66 58 }); 67 59 68 - // Snapshot the original cards so savePage can detect deletions. 60 + // Snapshot the original cards and sections so savePage can detect deletions. 69 61 const originalCards: Item[] = structuredClone(data.cards); 62 + const originalSections: SectionRecord[] = structuredClone(data.sections); 70 63 71 64 // svelte-ignore state_referenced_locally 72 65 let items: Item[] = $state(data.cards); 73 - 74 - // Flag set by checkData when overlapping cards were auto-fixed on load 75 - let showLayoutFixModal = $state(data.hasLayoutIssue ?? false); 76 - 77 - function acknowledgeLayoutFix() { 78 - hasUnsavedChanges = true; 79 - showLayoutFixModal = false; 80 - } 66 + // svelte-ignore state_referenced_locally 67 + let sections: SectionRecord[] = $state(data.sections); 81 68 82 69 // svelte-ignore state_referenced_locally 83 70 let publication = $state(JSON.stringify(data.publication)); ··· 105 92 } 106 93 }); 107 94 108 - // Warn user before closing tab if there are unsaved changes 109 - $effect(() => { 110 - function handleBeforeUnload(e: BeforeUnloadEvent) { 111 - if (hasUnsavedChanges) { 112 - e.preventDefault(); 113 - return ''; 114 - } 115 - } 116 - 117 - window.addEventListener('beforeunload', handleBeforeUnload); 118 - return () => window.removeEventListener('beforeunload', handleBeforeUnload); 119 - }); 120 - 121 - // Press Escape to deselect the currently selected card. 122 - $effect(() => { 123 - function handleKeydown(e: KeyboardEvent) { 124 - if (e.key === 'Escape' && selectedCardId) { 125 - selectedCardId = null; 126 - } 127 - } 128 - 129 - window.addEventListener('keydown', handleKeydown); 130 - return () => window.removeEventListener('keydown', handleKeydown); 131 - }); 132 - 133 - let gridContainer: HTMLDivElement | undefined = $state(); 95 + let gridRefs = new SvelteMap<string, HTMLDivElement>(); 134 96 135 97 let showingMobileView = $state(false); 136 98 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); ··· 165 127 selectedCardId = id; 166 128 }); 167 129 168 - const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 169 - const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 170 - let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 130 + let activeSectionId = $state(sections[0]?.id); 131 + let gridContainer = $derived( 132 + activeSectionId ? gridRefs.get(activeSectionId) : gridRefs.values().next().value 133 + ); 171 134 172 135 function newCard(type: string = 'link', cardData?: any) { 173 136 selectedCardId = null; 174 137 175 - // close sidebar if open 176 - const popover = document.getElementById('mobile-menu'); 177 - if (popover) { 178 - popover.hidePopover(); 179 - } 180 - 181 - let item = createEmptyCard(data.page); 138 + let item = createEmptyCard(data.page, activeSectionId); 182 139 item.cardType = type; 183 140 184 141 item.cardData = cardData ?? {}; ··· 213 170 setTimeout(restore, 50); 214 171 } 215 172 216 - async function saveNewItem() { 217 - if (!newItem.item) return; 218 - const item = newItem.item; 173 + function addItem(item: Item) { 174 + const sectionId = item.sectionId; 175 + const section = sections.find((s) => s.id === sectionId); 176 + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; 177 + const ref = sectionId ? gridRefs.get(sectionId) : gridContainer; 219 178 220 - const viewportCenter = gridContainer 221 - ? getViewportCenterGridY(gridContainer, isMobile) 222 - : undefined; 223 - setPositionOfNewItem(item, items, viewportCenter); 179 + if (def?.addItem) { 180 + items = def.addItem(item, items, { gridRef: ref, isMobile }); 181 + } else { 182 + items = [...items, item]; 183 + } 224 184 225 - items = [...items, item]; 185 + onLayoutChanged(); 186 + return ref; 187 + } 226 188 227 - // Push overlapping items down, then compact to fill gaps 228 - fixCollisions(items, item, false, true); 229 - fixCollisions(items, item, true, true); 230 - compactItems(items, false); 231 - compactItems(items, true); 189 + async function createFileCards(files: File[]): Promise<Item[]> { 190 + const cards: Item[] = []; 191 + for (const file of files) { 192 + if (file.type.startsWith('video/')) { 193 + cards.push(createVideoCard(file, data.page)); 194 + } else { 195 + cards.push(await createImageCard(file, data.page)); 196 + } 197 + } 198 + return cards; 199 + } 232 200 233 - onLayoutChanged(); 201 + async function saveNewItem() { 202 + if (!newItem.item) return; 203 + const item = newItem.item; 204 + 205 + const ref = addItem(item); 234 206 235 207 newItem = {}; 236 208 237 209 await tick(); 238 210 cleanupDialogArtifacts(); 239 211 240 - scrollToItem(item, isMobile, gridContainer); 212 + scrollToItem(item, isMobile, ref); 241 213 } 242 214 243 215 let isSaving = $state(false); ··· 261 233 data.publication.preferences ??= {}; 262 234 data.publication.preferences.editedOn = editedOn; 263 235 264 - await savePage(data, items, originalCards, publication); 236 + data.sections = sections; 237 + await savePage(data, items, originalCards, publication, originalSections); 265 238 266 239 publication = JSON.stringify(data.publication); 267 240 savedPronouns = JSON.stringify(data.pronounsRecord); ··· 281 254 } 282 255 } 283 256 257 + let showSectionsModal = $state(false); 258 + 259 + function addSection(sectionType: string, afterIndex?: number) { 260 + const sorted = sections.toSorted((a, b) => a.index - b.index); 261 + let newIndex: number; 262 + if (afterIndex !== undefined && afterIndex < sorted.length) { 263 + const curr = sorted[afterIndex].index; 264 + const next = afterIndex + 1 < sorted.length ? sorted[afterIndex + 1].index : curr + 200; 265 + newIndex = Math.floor((curr + next) / 2); 266 + } else { 267 + newIndex = (sorted[sorted.length - 1]?.index ?? 0) + 100; 268 + } 269 + 270 + const section: SectionRecord = { 271 + id: TID.now(), 272 + sectionType, 273 + page: data.page, 274 + index: newIndex, 275 + sectionData: {}, 276 + version: 1 277 + }; 278 + sections = [...sections, section]; 279 + hasUnsavedChanges = true; 280 + } 281 + 282 + function deleteSection(id: string) { 283 + if (sections.length <= 1) return; 284 + items = items.filter((i) => i.sectionId !== id); 285 + sections = sections.filter((s) => s.id !== id); 286 + if (activeSectionId === id) { 287 + activeSectionId = sections[0]?.id; 288 + } 289 + hasUnsavedChanges = true; 290 + } 291 + 284 292 let linkValue = $state(''); 285 293 286 294 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 289 297 toast.error('invalid link'); 290 298 return; 291 299 } 292 - let item = createEmptyCard(data.page); 300 + let item = createEmptyCard(data.page, activeSectionId); 293 301 294 302 if (specificCardDef?.onUrlHandler?.(link, item)) { 295 303 item.cardType = specificCardDef.type; ··· 313 321 } 314 322 } 315 323 316 - function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 317 - return new Promise((resolve) => { 318 - const img = new Image(); 319 - img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 320 - img.onerror = () => resolve({ width: 1, height: 1 }); 321 - img.src = src; 322 - }); 323 - } 324 - 325 - function getBestGridSize( 326 - imageWidth: number, 327 - imageHeight: number, 328 - candidates: [number, number][] 329 - ): [number, number] { 330 - const imageRatio = imageWidth / imageHeight; 331 - let best: [number, number] = candidates[0]; 332 - let bestDiff = Infinity; 333 - 334 - for (const candidate of candidates) { 335 - const gridRatio = candidate[0] / candidate[1]; 336 - const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 337 - if (diff < bestDiff) { 338 - bestDiff = diff; 339 - best = candidate; 340 - } 341 - } 342 - 343 - return best; 344 - } 345 - 346 - const desktopSizeCandidates: [number, number][] = [ 347 - [2, 2], 348 - [2, 4], 349 - [4, 2], 350 - [4, 4], 351 - [4, 6], 352 - [6, 4] 353 - ]; 354 - const mobileSizeCandidates: [number, number][] = [ 355 - [4, 4], 356 - [4, 6], 357 - [4, 8], 358 - [6, 4], 359 - [8, 4], 360 - [8, 6] 361 - ]; 362 - 363 - async function processImageFile(file: File, gridX?: number, gridY?: number) { 364 - const isGif = file.type === 'image/gif'; 365 - 366 - // Don't compress GIFs to preserve animation 367 - const objectUrl = URL.createObjectURL(file); 368 - 369 - let item = createEmptyCard(data.page); 370 - 371 - item.cardType = isGif ? 'gif' : 'image'; 372 - item.cardData = { 373 - image: { blob: file, objectUrl } 374 - }; 375 - 376 - // Size card based on image aspect ratio 377 - const { width, height } = await getImageDimensions(objectUrl); 378 - const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 379 - const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 380 - item.w = dw; 381 - item.h = dh; 382 - item.mobileW = mw; 383 - item.mobileH = mh; 384 - 385 - // If grid position is provided (image dropped on grid) 386 - if (gridX !== undefined && gridY !== undefined) { 387 - if (isMobile) { 388 - item.mobileX = gridX; 389 - item.mobileY = gridY; 390 - // Derive desktop Y from mobile 391 - item.x = Math.floor((COLUMNS - item.w) / 2); 392 - item.x = Math.floor(item.x / 2) * 2; 393 - item.y = Math.max(0, Math.round(gridY / 2)); 394 - } else { 395 - item.x = gridX; 396 - item.y = gridY; 397 - // Derive mobile Y from desktop 398 - item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 399 - item.mobileX = Math.floor(item.mobileX / 2) * 2; 400 - item.mobileY = Math.max(0, Math.round(gridY * 2)); 401 - } 402 - 403 - items = [...items, item]; 404 - fixCollisions(items, item, isMobile); 405 - fixCollisions(items, item, !isMobile); 406 - } else { 407 - const viewportCenter = gridContainer 408 - ? getViewportCenterGridY(gridContainer, isMobile) 409 - : undefined; 410 - setPositionOfNewItem(item, items, viewportCenter); 411 - items = [...items, item]; 412 - fixCollisions(items, item, false, true); 413 - fixCollisions(items, item, true, true); 414 - compactItems(items, false); 415 - compactItems(items, true); 416 - } 417 - 418 - onLayoutChanged(); 419 - 420 - await tick(); 421 - 422 - scrollToItem(item, isMobile, gridContainer); 423 - } 424 - 425 - async function handleFileDrop(files: File[], gridX: number, gridY: number) { 426 - for (let i = 0; i < files.length; i++) { 427 - // First image gets the drop position, rest use normal placement 428 - if (i === 0) { 429 - await processImageFile(files[i], gridX, gridY); 430 - } else { 431 - await processImageFile(files[i]); 432 - } 433 - } 434 - } 435 - 436 - async function handleImageInputChange(event: Event) { 324 + async function handleFileInputChange(event: Event) { 437 325 const target = event.target as HTMLInputElement; 438 326 if (!target.files || target.files.length < 1) return; 439 327 440 - const files = Array.from(target.files); 441 - 442 - if (files.length === 1) { 443 - // Single file: use default positioning 444 - await processImageFile(files[0]); 445 - } else { 446 - // Multiple files: place in grid pattern starting from first available position 447 - let gridX = 0; 448 - let gridY = maxHeight; 449 - const cardW = isMobile ? 4 : 2; 450 - const cardH = isMobile ? 4 : 2; 451 - 452 - for (const file of files) { 453 - await processImageFile(file, gridX, gridY); 454 - 455 - // Move to next cell position 456 - gridX += cardW; 457 - if (gridX + cardW > COLUMNS) { 458 - gridX = 0; 459 - gridY += cardH; 460 - } 461 - } 328 + const cards = await createFileCards(Array.from(target.files)); 329 + for (const card of cards) { 330 + card.sectionId = activeSectionId; 331 + addItem(card); 462 332 } 463 333 464 - // Reset the input so the same file can be selected again 465 334 target.value = ''; 466 335 } 467 336 468 - async function processVideoFile(file: File) { 469 - const objectUrl = URL.createObjectURL(file); 470 - 471 - let item = createEmptyCard(data.page); 337 + let showCardCommand = $state(false); 338 + </script> 472 339 473 - item.cardType = 'video'; 474 - item.cardData = { 475 - blob: file, 476 - objectUrl 477 - }; 478 - 479 - const viewportCenter = gridContainer 480 - ? getViewportCenterGridY(gridContainer, isMobile) 481 - : undefined; 482 - setPositionOfNewItem(item, items, viewportCenter); 483 - items = [...items, item]; 484 - fixCollisions(items, item, false, true); 485 - fixCollisions(items, item, true, true); 486 - compactItems(items, false); 487 - compactItems(items, true); 488 - 489 - onLayoutChanged(); 490 - 491 - await tick(); 492 - 493 - scrollToItem(item, isMobile, gridContainer); 494 - } 495 - 496 - async function handleVideoInputChange(event: Event) { 497 - const target = event.target as HTMLInputElement; 498 - if (!target.files || target.files.length < 1) return; 499 - 500 - const files = Array.from(target.files); 501 - 502 - for (const file of files) { 503 - await processVideoFile(file); 340 + <svelte:window 341 + onbeforeunload={(e) => { 342 + if (hasUnsavedChanges) { 343 + e.preventDefault(); 344 + return ''; 504 345 } 505 - 506 - // Reset the input so the same file can be selected again 507 - target.value = ''; 508 - } 509 - 510 - let showCardCommand = $state(false); 511 - </script> 346 + }} 347 + /> 512 348 513 349 <svelte:body 350 + onkeydown={(e) => { 351 + if (e.key === 'Escape' && selectedCardId) { 352 + selectedCardId = null; 353 + } 354 + }} 514 355 onpaste={(event) => { 515 356 if (isTyping()) return; 516 357 ··· 537 378 <CardCommand 538 379 bind:open={showCardCommand} 539 380 onselect={(cardDef: CardDefinition) => { 540 - if (cardDef.type === 'image') { 541 - const input = document.getElementById('image-input') as HTMLInputElement; 542 - if (input) { 543 - input.click(); 544 - return; 545 - } 546 - } else if (cardDef.type === 'video') { 547 - const input = document.getElementById('video-input') as HTMLInputElement; 381 + if (cardDef.type === 'image' || cardDef.type === 'video') { 382 + const input = document.getElementById('file-input') as HTMLInputElement; 548 383 if (input) { 549 384 input.click(); 550 385 return; 551 386 } 552 - } else { 553 - newCard(cardDef.type); 554 387 } 388 + newCard(cardDef.type); 555 389 }} 556 390 onlink={(url, cardDef) => { 557 391 addLink(url, cardDef); ··· 588 422 page={data.page} 589 423 /> 590 424 591 - <Modal open={showLayoutFixModal} closeButton={false}> 592 - <div class="flex flex-col items-center gap-4 text-center"> 593 - <svg 594 - xmlns="http://www.w3.org/2000/svg" 595 - fill="none" 596 - viewBox="0 0 24 24" 597 - stroke-width="1.5" 598 - stroke="currentColor" 599 - class="size-10 text-amber-500" 600 - > 601 - <path 602 - stroke-linecap="round" 603 - stroke-linejoin="round" 604 - d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" 605 - /> 606 - </svg> 607 - <p class="text-base-700 dark:text-base-300 text-xl font-bold">Layout Auto-Fixed</p> 608 - <p class="text-base-500 dark:text-base-400 text-sm"> 609 - Your card layout had overlapping cards from an older version. This has been automatically 610 - fixed, but some cards may have moved. Please check your layout and rearrange if needed, then 611 - save to keep the changes. 612 - </p> 613 - <Button class="w-full" onclick={acknowledgeLayoutFix}>Got it</Button> 614 - </div> 615 - </Modal> 425 + <SectionsModal 426 + bind:open={showSectionsModal} 427 + bind:sections 428 + ondelete={deleteSection} 429 + onlayoutchange={() => (hasUnsavedChanges = true)} 430 + /> 616 431 617 432 <Modal open={showMobileWarning} closeButton={false}> 618 433 <div class="flex flex-col items-center gap-4 text-center"> ··· 640 455 641 456 <div 642 457 class={[ 643 - '@container/wrapper relative w-full', 458 + 'group/wrapper @container/wrapper relative w-full', 644 459 showingMobileView 645 460 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dvh-2em)] overflow-hidden rounded-2xl lg:mx-auto lg:w-90' 646 461 : '' ··· 659 474 ]} 660 475 > 661 476 <div class="pointer-events-none"></div> 662 - <EditableGrid 663 - bind:items 664 - bind:ref={gridContainer} 665 - {isMobile} 666 - {selectedCardId} 667 - {isCoarse} 668 - onlayoutchange={onLayoutChanged} 669 - ondeselect={() => { 670 - selectedCardId = null; 671 - }} 672 - onfiledrop={handleFileDrop} 673 - > 674 - {#each items as item, i (item.id)} 675 - <BaseEditingCard 676 - bind:item={items[i]} 677 - ondelete={() => { 678 - items = items.filter((it) => it !== item); 679 - compactItems(items, false); 680 - compactItems(items, true); 681 - onLayoutChanged(); 682 - }} 683 - onsetsize={(newW: number, newH: number) => { 684 - if (isMobile) { 685 - item.mobileW = newW; 686 - item.mobileH = newH; 687 - } else { 688 - item.w = newW; 689 - item.h = newH; 690 - } 691 - 692 - fixCollisions(items, item, isMobile); 693 - onLayoutChanged(); 694 - }} 695 - > 696 - <EditingCard bind:item={items[i]} /> 697 - </BaseEditingCard> 477 + <div class="@5xl/wrapper:col-start-2 @5xl/wrapper:-col-end-1"> 478 + {#each sections.toSorted((a, b) => a.index - b.index) as section, i (section.id)} 479 + {@const def = SectionDefinitionsByType[section.sectionType]} 480 + {#if def} 481 + <def.editingContentComponent 482 + {section} 483 + bind:items 484 + {isMobile} 485 + {selectedCardId} 486 + {isCoarse} 487 + isActive={activeSectionId === section.id} 488 + onrefchange={(el) => { 489 + if (el) gridRefs.set(section.id, el); 490 + else gridRefs.delete(section.id); 491 + }} 492 + onlayoutchange={onLayoutChanged} 493 + ondeselect={() => { 494 + selectedCardId = null; 495 + }} 496 + onadditem={(item) => addItem(item)} 497 + oncreatefilecards={createFileCards} 498 + onactivate={() => { 499 + activeSectionId = section.id; 500 + }} 501 + /> 502 + {/if} 503 + <AddSectionButton onadd={(type) => addSection(type, i)} /> 698 504 {/each} 699 - </EditableGrid> 505 + <div class="h-20"></div> 506 + </div> 700 507 </div> 701 508 </div> 702 509 ··· 709 516 {newCard} 710 517 {addLink} 711 518 {save} 712 - {handleImageInputChange} 713 - {handleVideoInputChange} 519 + {handleFileInputChange} 714 520 showCardCommand={() => { 715 521 showCardCommand = true; 716 522 }} ··· 722 528 }} 723 529 ondelete={() => { 724 530 if (selectedCard) { 725 - items = items.filter((it) => it.id !== selectedCardId); 726 - compactItems(items, false); 727 - compactItems(items, true); 531 + const section = sections.find((s) => s.id === selectedCard!.sectionId); 532 + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; 533 + if (def?.deleteItem) { 534 + items = def.deleteItem(selectedCardId!, items, selectedCard.sectionId!); 535 + } else { 536 + items = items.filter((it) => it.id !== selectedCardId); 537 + } 728 538 onLayoutChanged(); 729 539 selectedCardId = null; 730 540 } 731 541 }} 732 542 onsetsize={(w: number, h: number) => { 733 543 if (selectedCard) { 734 - if (isMobile) { 735 - selectedCard.mobileW = w; 736 - selectedCard.mobileH = h; 737 - } else { 738 - selectedCard.w = w; 739 - selectedCard.h = h; 544 + const section = sections.find((s) => s.id === selectedCard!.sectionId); 545 + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; 546 + if (def?.resizeItem) { 547 + def.resizeItem(selectedCard, items, w, h, isMobile); 740 548 } 741 - fixCollisions(items, selectedCard, isMobile); 742 549 onLayoutChanged(); 743 550 } 551 + }} 552 + showSectionsModal={() => { 553 + showSectionsModal = true; 744 554 }} 745 555 /> 746 556 747 557 <Toaster /> 748 558 749 559 <FloatingEditButton {data} /> 750 - 751 - {#if dev} 752 - <div 753 - class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs" 754 - > 755 - <span>editedOn: {editedOn}</span> 756 - </div> 757 - {/if} 758 560 </Context>
+140
src/lib/website/SectionsModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Modal } from '@foxui/core'; 3 + import type { SectionRecord } from '$lib/types'; 4 + import { SectionDefinitionsByType } from '$lib/sections'; 5 + 6 + let { 7 + open = $bindable(false), 8 + sections = $bindable(), 9 + ondelete, 10 + onlayoutchange 11 + }: { 12 + open: boolean; 13 + sections: SectionRecord[]; 14 + ondelete: (id: string) => void; 15 + onlayoutchange: () => void; 16 + } = $props(); 17 + 18 + function moveUp(index: number) { 19 + if (index <= 0) return; 20 + const sorted = sections.toSorted((a, b) => a.index - b.index); 21 + const prev = sorted[index - 1]; 22 + const curr = sorted[index]; 23 + const tmpIndex = prev.index; 24 + prev.index = curr.index; 25 + curr.index = tmpIndex; 26 + sections = [...sections]; 27 + onlayoutchange(); 28 + } 29 + 30 + function moveDown(index: number) { 31 + const sorted = sections.toSorted((a, b) => a.index - b.index); 32 + if (index >= sorted.length - 1) return; 33 + const next = sorted[index + 1]; 34 + const curr = sorted[index]; 35 + const tmpIndex = next.index; 36 + next.index = curr.index; 37 + curr.index = tmpIndex; 38 + sections = [...sections]; 39 + onlayoutchange(); 40 + } 41 + </script> 42 + 43 + <Modal bind:open> 44 + <div class="flex flex-col gap-4"> 45 + <h2 class="text-lg font-semibold">Sections</h2> 46 + 47 + {#if sections.length === 0} 48 + <p class="text-base-500 dark:text-base-400 text-sm">No sections yet.</p> 49 + {/if} 50 + 51 + <div class="flex flex-col gap-2"> 52 + {#each sections.toSorted((a, b) => a.index - b.index) as section, i (section.id)} 53 + {@const def = SectionDefinitionsByType[section.sectionType]} 54 + <div 55 + class="border-base-200 dark:border-base-800 flex items-center gap-3 rounded-xl border p-3" 56 + > 57 + {#if def?.icon} 58 + <span class="text-base-500 dark:text-base-400 shrink-0"> 59 + {@html def.icon} 60 + </span> 61 + {/if} 62 + 63 + <div class="flex flex-1 flex-col"> 64 + <span class="text-sm font-medium"> 65 + {section.name || def?.name || section.sectionType} 66 + </span> 67 + </div> 68 + 69 + <div class="flex items-center gap-1"> 70 + <Button 71 + size="sm" 72 + variant="ghost" 73 + disabled={i === 0} 74 + onclick={() => moveUp(i)} 75 + class="size-8 min-w-8" 76 + > 77 + <svg 78 + xmlns="http://www.w3.org/2000/svg" 79 + viewBox="0 0 24 24" 80 + fill="none" 81 + stroke="currentColor" 82 + stroke-width="2" 83 + stroke-linecap="round" 84 + stroke-linejoin="round" 85 + class="size-4" 86 + > 87 + <path d="m18 15-6-6-6 6" /> 88 + </svg> 89 + </Button> 90 + <Button 91 + size="sm" 92 + variant="ghost" 93 + disabled={i === sections.length - 1} 94 + onclick={() => moveDown(i)} 95 + class="size-8 min-w-8" 96 + > 97 + <svg 98 + xmlns="http://www.w3.org/2000/svg" 99 + viewBox="0 0 24 24" 100 + fill="none" 101 + stroke="currentColor" 102 + stroke-width="2" 103 + stroke-linecap="round" 104 + stroke-linejoin="round" 105 + class="size-4" 106 + > 107 + <path d="m6 9 6 6 6-6" /> 108 + </svg> 109 + </Button> 110 + {#if sections.length > 1} 111 + <Button 112 + size="sm" 113 + variant="ghost" 114 + onclick={() => ondelete(section.id)} 115 + class="size-8 min-w-8 text-rose-500" 116 + > 117 + <svg 118 + xmlns="http://www.w3.org/2000/svg" 119 + viewBox="0 0 24 24" 120 + fill="none" 121 + stroke="currentColor" 122 + stroke-width="2" 123 + stroke-linecap="round" 124 + stroke-linejoin="round" 125 + class="size-4" 126 + > 127 + <path d="M3 6h18" /> 128 + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> 129 + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> 130 + </svg> 131 + </Button> 132 + {/if} 133 + </div> 134 + </div> 135 + {/each} 136 + </div> 137 + 138 + <Button variant="ghost" onclick={() => (open = false)}>Done</Button> 139 + </div> 140 + </Modal>
+14 -17
src/lib/website/Website.svelte
··· 1 1 <script lang="ts"> 2 - import Card from '../cards/_base/Card/Card.svelte'; 3 2 import Profile from './Profile.svelte'; 4 3 import { 5 4 getDescription, 6 5 getHideProfileSection, 7 6 getProfilePosition, 8 7 getName, 9 - sortItems, 10 8 getImage 11 9 } from '../helper'; 12 10 import { innerWidth } from 'svelte/reactivity/window'; 13 11 import { setDidContext, setHandleContext, setIsMobile } from './context'; 14 - import BaseCard from '../cards/_base/BaseCard/BaseCard.svelte'; 15 12 import type { WebsiteData } from '$lib/types'; 16 13 import Context from './Context.svelte'; 14 + import { SectionDefinitionsByType } from '$lib/sections'; 17 15 import MadeWithBlento from './MadeWithBlento.svelte'; 18 16 import Head from './Head.svelte'; 19 17 import type { Did, Handle } from '@atcute/lexicons'; ··· 55 53 return `${origin}/${actor}/og-new.png`; 56 54 }); 57 55 58 - let maxHeight = $derived( 59 - data.cards.reduce( 60 - (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), 61 - 0 62 - ) 63 - ); 64 - 65 56 let container: HTMLDivElement | undefined = $state(); 66 57 </script> 67 58 ··· 91 82 ]} 92 83 > 93 84 <div></div> 94 - <div bind:this={container} class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 85 + <div class="@5xl/wrapper:col-start-2 @5xl/wrapper:-col-end-1"> 95 86 {#if data.cards.length === 0 && data.page === 'blento.self'} 96 - <EmptyState {data} /> 87 + <div bind:this={container} class="@container/grid relative px-2 py-8 lg:px-8"> 88 + <EmptyState {data} /> 89 + </div> 97 90 {:else} 98 - {#each data.cards.toSorted(sortItems) as item (item.id)} 99 - <BaseCard {item}> 100 - <Card {item} /> 101 - </BaseCard> 91 + {#each data.sections.toSorted((a, b) => a.index - b.index) as section (section.id)} 92 + {@const def = SectionDefinitionsByType[section.sectionType]} 93 + {#if def} 94 + <def.contentComponent 95 + {section} 96 + items={data.cards.filter((c) => c.sectionId === section.id)} 97 + {isMobile} 98 + /> 99 + {/if} 102 100 {/each} 103 - <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 104 101 {/if} 105 102 </div> 106 103 </div>
+79
src/lib/website/file-processing.ts
··· 1 + import { createEmptyCard } from '$lib/helper'; 2 + import type { Item } from '$lib/types'; 3 + 4 + export function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 5 + return new Promise((resolve) => { 6 + const img = new Image(); 7 + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 8 + img.onerror = () => resolve({ width: 1, height: 1 }); 9 + img.src = src; 10 + }); 11 + } 12 + 13 + export function getBestGridSize( 14 + imageWidth: number, 15 + imageHeight: number, 16 + candidates: [number, number][] 17 + ): [number, number] { 18 + const imageRatio = imageWidth / imageHeight; 19 + let best = candidates[0]; 20 + let bestDiff = Infinity; 21 + 22 + for (const [w, h] of candidates) { 23 + const gridRatio = w / h; 24 + const diff = Math.abs(gridRatio - imageRatio); 25 + if (diff < bestDiff) { 26 + bestDiff = diff; 27 + best = [w, h]; 28 + } 29 + } 30 + 31 + return best; 32 + } 33 + 34 + const desktopSizeCandidates: [number, number][] = [ 35 + [2, 2], 36 + [2, 4], 37 + [4, 2], 38 + [4, 4], 39 + [4, 6], 40 + [6, 4] 41 + ]; 42 + 43 + const mobileSizeCandidates: [number, number][] = [ 44 + [4, 4], 45 + [4, 6], 46 + [4, 8], 47 + [6, 4], 48 + [8, 4], 49 + [8, 6] 50 + ]; 51 + 52 + export async function createImageCard(file: File, page: string, sectionId?: string): Promise<Item> { 53 + const isGif = file.type === 'image/gif'; 54 + const objectUrl = URL.createObjectURL(file); 55 + 56 + const item = createEmptyCard(page, sectionId); 57 + item.cardType = isGif ? 'gif' : 'image'; 58 + item.cardData = { image: { blob: file, objectUrl } }; 59 + 60 + const { width, height } = await getImageDimensions(objectUrl); 61 + const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 62 + const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 63 + item.w = dw; 64 + item.h = dh; 65 + item.mobileW = mw; 66 + item.mobileH = mh; 67 + 68 + return item; 69 + } 70 + 71 + export function createVideoCard(file: File, page: string, sectionId?: string): Item { 72 + const objectUrl = URL.createObjectURL(file); 73 + 74 + const item = createEmptyCard(page, sectionId); 75 + item.cardType = 'video'; 76 + item.cardData = { blob: file, objectUrl }; 77 + 78 + return item; 79 + }
+37 -8
src/lib/website/load.ts
··· 3 3 import { CardDefinitionsByType } from '$lib/cards'; 4 4 import type { CacheService } from '$lib/cache'; 5 5 import { createEmptyCard } from '$lib/helper'; 6 - import type { Item, PronounsRecord, WebsiteData } from '$lib/types'; 6 + import type { Item, PronounsRecord, SectionRecord, WebsiteData } from '$lib/types'; 7 + import { ensureSections } from '$lib/sections/migrate'; 7 8 import { error } from '@sveltejs/kit'; 8 9 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 9 10 ··· 137 138 * for card types that actually appear on the current page. 138 139 */ 139 140 function loadFromContrail(actor: ActorIdentifier, db: D1Database, page: string) { 140 - return tryContrail('cards+pages query', async () => { 141 + return tryContrail('cards+pages+sections query', async () => { 141 142 const client = getServerClient(db); 142 - const [cardRes, pageRes] = await Promise.all([ 143 + const [cardRes, pageRes, sectionRes] = await Promise.all([ 143 144 client.get('app.blento.card.listRecords', { 144 145 params: { actor, limit: 200, profiles: true, page } 145 146 }), 146 147 client.get('app.blento.page.listRecords', { 147 148 params: { actor, limit: 200 } 148 - }) 149 + }), 150 + client.get('app.blento.section.listRecords' as any, { 151 + params: { actor, limit: 200, page } 152 + }) as Promise<any> 149 153 ]); 150 154 151 155 if (!cardRes.ok) return null; ··· 162 166 })) 163 167 : []; 164 168 169 + const sections = 170 + sectionRes?.ok && sectionRes.data?.records 171 + ? (sectionRes.data.records as any[]).map( 172 + (r: any) => ({ ...(r.record as object), id: parseUri(r.uri)?.rkey }) as SectionRecord 173 + ) 174 + : []; 175 + 165 176 return { 166 177 cards, 167 178 pages, 179 + sections, 168 180 profiles: (cardRes.data.profiles ?? []) as ContrailProfile[] 169 181 }; 170 182 }); ··· 213 225 const contrailData = db ? await loadFromContrail(handle, db, fullPage) : null; 214 226 215 227 let cards: Item[]; 228 + let sectionRecords: SectionRecord[] = []; 216 229 let pageRecords: Awaited<ReturnType<typeof listRecords>>; 217 230 let profile: WebsiteData['profile']; 218 231 let publication: WebsiteData['publication'] | undefined; ··· 220 233 221 234 if (contrailData) { 222 235 cards = contrailData.cards; 236 + sectionRecords = contrailData.sections; 223 237 pageRecords = contrailData.pages; 224 238 225 239 const extracted = extractProfileData(did, contrailData.profiles); ··· 228 242 pronounsRecord = extracted.pronounsRecord; 229 243 } else { 230 244 // Fallback: no D1 available (e.g. vite dev) — use PDS directly 231 - const [cardRecords, pageRecs, mainPub, prof, pronouns] = await Promise.all([ 245 + const [cardRecords, pageRecs, sectionRecs, mainPub, prof, pronouns] = await Promise.all([ 232 246 listRecords({ did, collection: 'app.blento.card', limit: 0 }).catch((e) => { 233 247 console.error('error getting records for collection app.blento.card', e); 234 248 return [] as Awaited<ReturnType<typeof listRecords>>; ··· 236 250 listRecords({ did, collection: 'app.blento.page' }).catch( 237 251 () => [] as Awaited<ReturnType<typeof listRecords>> 238 252 ), 253 + listRecords({ did, collection: 'app.blento.section' }).catch( 254 + () => [] as Awaited<ReturnType<typeof listRecords>> 255 + ), 239 256 getSelfPublicationFromPDS(did), 240 257 getDetailedProfile({ did }), 241 258 getPronounsFromPDS(did) 242 259 ]); 243 260 244 261 cards = cardRecords.map((v) => ({ ...v.value }) as Item); 262 + sectionRecords = sectionRecs 263 + .filter((v) => (v.value as any)?.page === fullPage) 264 + .map((v) => ({ ...(v.value as object), id: parseUri(v.uri)?.rkey }) as SectionRecord); 245 265 pageRecords = pageRecs; 246 266 profile = prof; 247 267 publication = mainPub?.value as WebsiteData['publication'] | undefined; ··· 256 276 257 277 publication ??= defaultPublication(profile); 258 278 259 - const additionalData = await loadAdditionalData(cards, { did, handle, cache, platform }, env); 279 + const migrated = ensureSections(sectionRecords, cards, fullPage); 280 + 281 + const additionalData = await loadAdditionalData( 282 + migrated.cards, 283 + { did, handle, cache, platform }, 284 + env 285 + ); 260 286 261 287 return checkData({ 262 - page: 'blento.' + page, 288 + page: fullPage, 263 289 handle, 264 290 did, 265 - cards, 291 + cards: migrated.cards, 292 + sections: migrated.sections, 266 293 publication, 267 294 additionalData, 268 295 profile, ··· 353 380 handle: resolvedHandle, 354 381 did, 355 382 cards, 383 + sections: [], 356 384 publication: publication ?? defaultPublication(profile), 357 385 additionalData, 358 386 profile, ··· 434 462 handle: resolvedHandle, 435 463 did, 436 464 cards, 465 + sections: [], 437 466 publication: publication ?? defaultPublication(profile), 438 467 additionalData, 439 468 profile,