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

Configure Feed

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

Merge pull request #281 from flo-bit/feat/sections

sections

authored by

Florian and committed by
GitHub
a2ebb80f 457434c0

+2492 -479
+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 -53
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 1 1 <script lang="ts"> 2 - import { COLUMNS, margin, mobileMargin } from '$lib'; 3 2 import type { Item } from '$lib/types'; 4 3 import type { WithElementRef } from 'bits-ui'; 5 4 import type { Snippet } from 'svelte'; 6 5 import type { HTMLAttributes } from 'svelte/elements'; 7 - import { getColor } from '../..'; 6 + import { CardDefinitionsByType, getColor } from '../..'; 8 7 9 8 const colors = { 10 9 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 17 16 controls?: Snippet<[]>; 18 17 isEditing?: boolean; 19 18 showOutline?: boolean; 20 - locked?: boolean; 21 - fillPage?: boolean; 22 19 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 23 20 24 21 let { ··· 28 25 isEditing = false, 29 26 controls, 30 27 showOutline, 31 - locked = false, 32 - fillPage = false, 33 28 class: className, 34 29 ...rest 35 30 }: BaseCardProps = $props(); 36 31 37 32 let color = $derived(getColor(item)); 33 + let noOverflow = $derived(CardDefinitionsByType[item.cardType]?.noOverflow ?? false); 38 34 </script> 39 35 40 36 <div 41 37 id={item.id} 42 38 data-flip-id={item.id} 43 - data-fill-page={fillPage ? 'true' : undefined} 44 39 bind:this={ref} 45 40 draggable={false} 46 41 class={[ 47 - fillPage 48 - ? 'card group/card selection:bg-accent-600/50 @container/card relative isolate z-0 h-full w-full outline-offset-2 transition-[outline] duration-200' 49 - : 'card group/card selection:bg-accent-600/50 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-[outline] duration-200', 42 + 'card group/card selection:bg-accent-600/50 @container/card relative isolate z-0 h-full w-full rounded-3xl outline-offset-2 transition-[outline] duration-200', 50 43 isEditing ? 'transition-all' : '', 51 - !fillPage ? (color ? (colors[color] ?? colors.accent) : colors.base) : '', 44 + color ? (colors[color] ?? colors.accent) : colors.base, 52 45 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 53 46 showOutline ? 'outline-2' : '', 54 47 className 55 48 ]} 56 - style={` 57 - --mx: ${item.mobileX}; 58 - --my: ${item.mobileY}; 59 - --mw: ${item.mobileW}; 60 - --mh: ${item.mobileH}; 61 - --mm: ${mobileMargin}px; 62 - 63 - --dx: ${item.x}; 64 - --dy: ${item.y}; 65 - --dw: ${item.w}; 66 - --dh: ${item.h}; 67 - --dm: ${margin}px; 68 - 69 - --columns: ${COLUMNS}`} 70 49 {...rest} 71 50 > 72 51 <div 73 52 class={[ 74 - 'text-base-900 dark:text-base-50 relative isolate h-full w-full overflow-hidden', 75 - !fillPage ? 'rounded-3xl' : '', 53 + 'text-base-900 dark:text-base-50 relative isolate h-full w-full rounded-3xl', 54 + noOverflow ? 'overflow-visible' : 'overflow-hidden', 76 55 color !== 'base' && color != 'transparent' ? 'light' : '' 77 56 ]} 78 57 > ··· 88 67 </div> 89 68 {@render controls?.()} 90 69 </div> 91 - 92 - <style> 93 - .card { 94 - container-name: card; 95 - container-type: size; 96 - translate: calc((var(--mx) / var(--columns)) * 100cqw + var(--mm)) 97 - calc((var(--my) / var(--columns)) * 100cqw + var(--mm)); 98 - width: calc((var(--mw) / var(--columns)) * 100cqw - (var(--mm) * 2)); 99 - height: calc((var(--mh) / var(--columns)) * 100cqw - (var(--mm) * 2)); 100 - } 101 - 102 - .card[data-fill-page='true'] { 103 - translate: none; 104 - width: 100%; 105 - height: 100%; 106 - } 107 - 108 - @container grid (width >= 42rem) { 109 - .card:not([data-fill-page='true']) { 110 - translate: calc((var(--dx) / var(--columns)) * 100cqw + var(--dm)) 111 - calc((var(--dy) / var(--columns)) * 100cqw + var(--dm)); 112 - width: calc((var(--dw) / var(--columns)) * 100cqw - (var(--dm) * 2)); 113 - height: calc((var(--dh) / var(--columns)) * 100cqw - (var(--dm) * 2)); 114 - } 115 - } 116 - </style>
+8 -7
src/lib/cards/_base/BaseCard/BaseEditingCard.svelte
··· 42 42 export type BaseEditingCardProps = { 43 43 item: Item; 44 44 ondelete: () => void; 45 - onsetsize: (newW: number, newH: number) => void; 45 + onsetsize?: (newW: number, newH: number) => void; 46 + showGridControls?: boolean; 46 47 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 47 48 48 49 let { ··· 51 52 ref = $bindable(null), 52 53 onsetsize, 53 54 ondelete, 55 + showGridControls = true, 54 56 ...rest 55 57 }: BaseEditingCardProps = $props(); 56 58 ··· 199 201 isEditing={true} 200 202 bind:ref 201 203 showOutline={isResizing || isSelected} 202 - locked={item.cardData?.locked} 203 204 class={[ 204 205 'scale-100 starting:scale-0 starting:opacity-0', 205 206 isSelected ? 'outline-accent-500 z-10' : '' ··· 358 359 </Popover> 359 360 {/if} 360 361 361 - {#if canSetSize(2, 2)} 362 + {#if showGridControls && canSetSize(2, 2)} 362 363 <button 363 364 onclick={() => { 364 365 setSize(2, 2); ··· 371 372 </button> 372 373 {/if} 373 374 374 - {#if canSetSize(4, 2)} 375 + {#if showGridControls && canSetSize(4, 2)} 375 376 <button 376 377 onclick={() => { 377 378 setSize(4, 2); ··· 382 383 <span class="sr-only">set size to 2x1</span> 383 384 </button> 384 385 {/if} 385 - {#if canSetSize(2, 4)} 386 + {#if showGridControls && canSetSize(2, 4)} 386 387 <button 387 388 onclick={() => { 388 389 setSize(2, 4); ··· 394 395 <span class="sr-only">set size to 1x2</span> 395 396 </button> 396 397 {/if} 397 - {#if canSetSize(4, 4)} 398 + {#if showGridControls && canSetSize(4, 4)} 398 399 <button 399 400 onclick={() => { 400 401 setSize(4, 4); ··· 443 444 </div> 444 445 </div> 445 446 446 - {#if cardDef.canResize !== false} 447 + {#if showGridControls && cardDef.canResize !== false} 447 448 <!-- Resize handle at bottom right corner --> 448 449 <div 449 450 role="separator"
+69
src/lib/cards/_base/BaseCard/GridBaseCard.svelte
··· 1 + <script lang="ts"> 2 + import { COLUMNS, margin, mobileMargin } from '$lib'; 3 + import type { Item } from '$lib/types'; 4 + import type { WithElementRef } from 'bits-ui'; 5 + import type { Snippet } from 'svelte'; 6 + import type { HTMLAttributes } from 'svelte/elements'; 7 + import BaseCard from './BaseCard.svelte'; 8 + 9 + export type GridBaseCardProps = { 10 + item: Item; 11 + controls?: Snippet<[]>; 12 + isEditing?: boolean; 13 + showOutline?: boolean; 14 + } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 15 + 16 + let { 17 + item, 18 + children, 19 + ref = $bindable(null), 20 + isEditing = false, 21 + controls, 22 + showOutline, 23 + class: className, 24 + ...rest 25 + }: GridBaseCardProps = $props(); 26 + </script> 27 + 28 + <div 29 + class={['grid-card absolute', isEditing ? 'transition-all' : '']} 30 + style={` 31 + --mx: ${item.mobileX}; 32 + --my: ${item.mobileY}; 33 + --mw: ${item.mobileW}; 34 + --mh: ${item.mobileH}; 35 + --mm: ${mobileMargin}px; 36 + 37 + --dx: ${item.x}; 38 + --dy: ${item.y}; 39 + --dw: ${item.w}; 40 + --dh: ${item.h}; 41 + --dm: ${margin}px; 42 + 43 + --columns: ${COLUMNS}`} 44 + {...rest} 45 + > 46 + <BaseCard {item} {isEditing} {showOutline} {controls} bind:ref class={className}> 47 + {@render children?.()} 48 + </BaseCard> 49 + </div> 50 + 51 + <style> 52 + .grid-card { 53 + container-name: card; 54 + container-type: size; 55 + translate: calc((var(--mx) / var(--columns)) * 100cqw + var(--mm)) 56 + calc((var(--my) / var(--columns)) * 100cqw + var(--mm)); 57 + width: calc((var(--mw) / var(--columns)) * 100cqw - (var(--mm) * 2)); 58 + height: calc((var(--mh) / var(--columns)) * 100cqw - (var(--mm) * 2)); 59 + } 60 + 61 + @container grid (width >= 42rem) { 62 + .grid-card { 63 + translate: calc((var(--dx) / var(--columns)) * 100cqw + var(--dm)) 64 + calc((var(--dy) / var(--columns)) * 100cqw + var(--dm)); 65 + width: calc((var(--dw) / var(--columns)) * 100cqw - (var(--dm) * 2)); 66 + height: calc((var(--dh) / var(--columns)) * 100cqw - (var(--dm) * 2)); 67 + } 68 + } 69 + </style>
+64
src/lib/cards/_base/BaseCard/GridBaseEditingCard.svelte
··· 1 + <script lang="ts"> 2 + import { COLUMNS, margin, mobileMargin } from '$lib'; 3 + import type { Item } from '$lib/types'; 4 + import type { WithElementRef } from 'bits-ui'; 5 + import type { HTMLAttributes } from 'svelte/elements'; 6 + import BaseEditingCard from './BaseEditingCard.svelte'; 7 + 8 + export type GridBaseEditingCardProps = { 9 + item: Item; 10 + ondelete: () => void; 11 + onsetsize: (newW: number, newH: number) => void; 12 + } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 13 + 14 + let { 15 + item = $bindable(), 16 + children, 17 + ref = $bindable(null), 18 + onsetsize, 19 + ondelete, 20 + ...rest 21 + }: GridBaseEditingCardProps = $props(); 22 + </script> 23 + 24 + <div 25 + class="grid-card absolute transition-all" 26 + style={` 27 + --mx: ${item.mobileX}; 28 + --my: ${item.mobileY}; 29 + --mw: ${item.mobileW}; 30 + --mh: ${item.mobileH}; 31 + --mm: ${mobileMargin}px; 32 + 33 + --dx: ${item.x}; 34 + --dy: ${item.y}; 35 + --dw: ${item.w}; 36 + --dh: ${item.h}; 37 + --dm: ${margin}px; 38 + 39 + --columns: ${COLUMNS}`} 40 + > 41 + <BaseEditingCard bind:item bind:ref {ondelete} {onsetsize} {...rest}> 42 + {@render children?.()} 43 + </BaseEditingCard> 44 + </div> 45 + 46 + <style> 47 + .grid-card { 48 + container-name: card; 49 + container-type: size; 50 + translate: calc((var(--mx) / var(--columns)) * 100cqw + var(--mm)) 51 + calc((var(--my) / var(--columns)) * 100cqw + var(--mm)); 52 + width: calc((var(--mw) / var(--columns)) * 100cqw - (var(--mm) * 2)); 53 + height: calc((var(--mh) / var(--columns)) * 100cqw - (var(--mm) * 2)); 54 + } 55 + 56 + @container grid (width >= 42rem) { 57 + .grid-card { 58 + translate: calc((var(--dx) / var(--columns)) * 100cqw + var(--dm)) 59 + calc((var(--dy) / var(--columns)) * 100cqw + var(--dm)); 60 + width: calc((var(--dw) / var(--columns)) * 100cqw - (var(--dm) * 2)); 61 + height: calc((var(--dh) / var(--columns)) * 100cqw - (var(--dm) * 2)); 62 + } 63 + } 64 + </style>
+84
src/lib/cards/sections/HeroCard/Decoration.svelte
··· 1 + <script lang="ts"> 2 + import { getDidContext } from '$lib/website/context'; 3 + import { getImage } from '$lib/helper'; 4 + import type { HeroDecoration } from '.'; 5 + 6 + let { 7 + decoration, 8 + isEditing = false, 9 + onclick 10 + }: { 11 + decoration: HeroDecoration; 12 + isEditing?: boolean; 13 + onclick?: () => void; 14 + } = $props(); 15 + 16 + const did = getDidContext(); 17 + const src = $derived(getImage(decoration as Record<string, any>, did, 'image')); 18 + const filled = $derived(Boolean(src)); 19 + 20 + const sideStyle = $derived( 21 + decoration.side === 'left' 22 + ? `left: 0; transform: translate(-65%, -50%) rotate(${decoration.rotation ?? 0}deg);` 23 + : `right: 0; transform: translate(65%, -50%) rotate(${decoration.rotation ?? 0}deg);` 24 + ); 25 + </script> 26 + 27 + {#snippet contents()} 28 + <img {src} alt="" class="aspect-square w-full object-cover" /> 29 + {#if decoration.title || decoration.subtitle} 30 + <div class="flex flex-col gap-0.5 p-2"> 31 + {#if decoration.title} 32 + <span class="text-base-950 dark:text-base-50 line-clamp-1 text-sm font-semibold"> 33 + {decoration.title} 34 + </span> 35 + {/if} 36 + {#if decoration.subtitle} 37 + <span class="text-base-500 dark:text-base-400 line-clamp-1 text-xs"> 38 + {decoration.subtitle} 39 + </span> 40 + {/if} 41 + </div> 42 + {/if} 43 + {/snippet} 44 + 45 + {#if filled && isEditing} 46 + <button 47 + type="button" 48 + class="dark:bg-base-900 bg-base-50 ring-base-900/5 dark:ring-base-50/10 absolute z-0 w-28 cursor-pointer overflow-hidden rounded-2xl text-left shadow-2xl ring-1 transition-transform hover:scale-[1.03] sm:w-44" 49 + style="top: {(decoration.top ?? 50) + '%'}; {sideStyle}" 50 + {onclick} 51 + > 52 + {@render contents()} 53 + </button> 54 + {:else if filled} 55 + <div 56 + class="dark:bg-base-900 bg-base-50 ring-base-900/5 dark:ring-base-50/10 absolute z-0 w-28 overflow-hidden rounded-2xl shadow-2xl ring-1 sm:w-44" 57 + style="top: {(decoration.top ?? 50) + '%'}; {sideStyle}" 58 + > 59 + {@render contents()} 60 + </div> 61 + {:else if isEditing} 62 + <button 63 + type="button" 64 + 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 pointer-events-auto absolute z-0 flex aspect-square w-28 cursor-pointer flex-col items-center justify-center gap-1 rounded-2xl border-2 border-dashed bg-white/40 opacity-40 backdrop-blur-sm transition-all duration-150 group-hover/hero:opacity-100 hover:scale-[1.03] hover:opacity-100 sm:w-44 dark:bg-black/20" 65 + style="top: {(decoration.top ?? 50) + '%'}; {sideStyle}" 66 + {onclick} 67 + aria-label="Add decoration" 68 + > 69 + <svg 70 + xmlns="http://www.w3.org/2000/svg" 71 + viewBox="0 0 24 24" 72 + fill="none" 73 + stroke="currentColor" 74 + stroke-width="2" 75 + stroke-linecap="round" 76 + stroke-linejoin="round" 77 + class="size-6" 78 + > 79 + <path d="M12 5v14" /> 80 + <path d="M5 12h14" /> 81 + </svg> 82 + <span class="text-xs font-medium">Add image</span> 83 + </button> 84 + {/if}
+118
src/lib/cards/sections/HeroCard/EditingHeroCard.svelte
··· 1 + <script lang="ts"> 2 + import { Badge, cn } from '@foxui/core'; 3 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 4 + import type { ContentComponentProps } from '../../types'; 5 + import { 6 + getHeroDecorations, 7 + heroAlignClasses, 8 + heroVerticalAlignClasses, 9 + type HeroDecoration 10 + } from '.'; 11 + import Decoration from './Decoration.svelte'; 12 + 13 + let { item = $bindable() }: ContentComponentProps = $props(); 14 + 15 + let align = $derived((item.cardData.textAlign as string) ?? 'center'); 16 + let vAlign = $derived((item.cardData.verticalAlign as string) ?? 'center'); 17 + let decorations = $derived(getHeroDecorations(item.cardData)); 18 + 19 + let fileInput: HTMLInputElement | undefined = $state(); 20 + let pendingSlotId: string | null = $state(null); 21 + 22 + function openImagePicker(slotId: string) { 23 + pendingSlotId = slotId; 24 + fileInput?.click(); 25 + } 26 + 27 + function handleFileChange(event: Event) { 28 + const input = event.target as HTMLInputElement; 29 + const file = input.files?.[0]; 30 + const slotId = pendingSlotId; 31 + input.value = ''; 32 + pendingSlotId = null; 33 + if (!file || !slotId) return; 34 + 35 + const stored = (item.cardData.decorations as HeroDecoration[]) ?? []; 36 + const objectUrl = URL.createObjectURL(file); 37 + const existingIdx = stored.findIndex((d) => d.id === slotId); 38 + const next = [...stored]; 39 + const patch = { image: { blob: file, objectUrl } }; 40 + if (existingIdx >= 0) { 41 + next[existingIdx] = { ...next[existingIdx], ...patch }; 42 + } else { 43 + const slotDefaults = decorations.find((d) => d.id === slotId); 44 + if (!slotDefaults) return; 45 + next.push({ ...slotDefaults, ...patch }); 46 + } 47 + item.cardData.decorations = next; 48 + } 49 + </script> 50 + 51 + <input 52 + bind:this={fileInput} 53 + type="file" 54 + accept="image/*" 55 + class="hidden" 56 + onchange={handleFileChange} 57 + /> 58 + 59 + <div class="group/hero relative h-full w-full"> 60 + {#each decorations as decoration (decoration.id)} 61 + <Decoration {decoration} isEditing onclick={() => openImagePicker(decoration.id)} /> 62 + {/each} 63 + 64 + <div 65 + class={cn( 66 + 'pointer-events-none relative z-10 flex h-full w-full flex-col gap-4 px-8 py-10 sm:px-12', 67 + heroAlignClasses[align], 68 + heroVerticalAlignClasses[vAlign] 69 + )} 70 + > 71 + {#if item.cardData.showBadge !== false} 72 + <Badge size="md" variant="primary"> 73 + <PlainTextEditor 74 + bind:contentDict={item.cardData} 75 + key="badge" 76 + placeholder="Badge" 77 + class="min-w-[2rem]" 78 + /> 79 + </Badge> 80 + {/if} 81 + 82 + <PlainTextEditor 83 + bind:contentDict={item.cardData} 84 + key="title" 85 + placeholder="My cool website" 86 + class="text-base-950 dark:text-base-50 accent:text-accent-950 w-full text-4xl font-bold tracking-tight text-balance sm:text-5xl md:text-6xl" 87 + /> 88 + 89 + {#if item.cardData.showSubtitle !== false} 90 + <PlainTextEditor 91 + bind:contentDict={item.cardData} 92 + key="subtitle" 93 + placeholder="Subtitle" 94 + class="text-base-600 dark:text-base-300 accent:text-accent-900/80 w-full max-w-2xl text-lg text-pretty sm:text-xl" 95 + /> 96 + {/if} 97 + 98 + {#if item.cardData.showButton !== false} 99 + <div 100 + class={cn( 101 + 'mt-2 flex w-full', 102 + align === 'center' 103 + ? 'justify-center' 104 + : align === 'right' 105 + ? 'justify-end' 106 + : 'justify-start' 107 + )} 108 + > 109 + <PlainTextEditor 110 + bind:contentDict={item.cardData} 111 + key="buttonText" 112 + placeholder="Button text" 113 + class="text-base-950 dark:text-base-50 accent:text-base-950 bg-accent-400 dark:bg-accent-500 inline-flex min-h-[1.5em] min-w-[6rem] items-center justify-center rounded-2xl px-6 py-3 text-xl font-semibold" 114 + /> 115 + </div> 116 + {/if} 117 + </div> 118 + </div>
+80
src/lib/cards/sections/HeroCard/HeroCard.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { user } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 5 + import { atProtoLoginModalState } from '@foxui/social'; 6 + import { Badge, cn } from '@foxui/core'; 7 + import type { ContentComponentProps } from '../../types'; 8 + import { getHeroDecorations, heroAlignClasses, heroVerticalAlignClasses } from '.'; 9 + import Decoration from './Decoration.svelte'; 10 + 11 + let { item }: ContentComponentProps = $props(); 12 + 13 + let align = $derived((item.cardData.textAlign as string) ?? 'center'); 14 + let vAlign = $derived((item.cardData.verticalAlign as string) ?? 'center'); 15 + let decorations = $derived(getHeroDecorations(item.cardData)); 16 + 17 + const buttonClass = 18 + 'text-base-950 dark:text-base-50 accent:text-base-950 mt-2 inline-flex cursor-pointer items-center justify-center rounded-2xl bg-accent-400 dark:bg-accent-500 px-6 py-3 text-xl font-semibold transition-colors duration-100 hover:bg-accent-400'; 19 + </script> 20 + 21 + <div class="relative h-full w-full"> 22 + {#each decorations as decoration (decoration.id)} 23 + <Decoration {decoration} /> 24 + {/each} 25 + 26 + <div 27 + class={cn( 28 + 'pointer-events-none relative z-10 flex h-full w-full flex-col gap-4 px-8 py-10 sm:px-12', 29 + heroAlignClasses[align], 30 + heroVerticalAlignClasses[vAlign] 31 + )} 32 + > 33 + {#if item.cardData.showBadge !== false && item.cardData.badge} 34 + <Badge size="md" variant="primary"> 35 + {item.cardData.badge} 36 + </Badge> 37 + {/if} 38 + 39 + <h1 40 + class="text-base-950 dark:text-base-50 accent:text-accent-950 text-4xl font-bold tracking-tight text-balance sm:text-5xl md:text-6xl" 41 + > 42 + {item.cardData.title || 'My cool website'} 43 + </h1> 44 + 45 + {#if item.cardData.showSubtitle !== false && item.cardData.subtitle} 46 + <p 47 + class="text-base-600 dark:text-base-300 accent:text-accent-900/80 max-w-2xl text-lg text-pretty sm:text-xl" 48 + > 49 + {item.cardData.subtitle} 50 + </p> 51 + {/if} 52 + 53 + {#if item.cardData.showButton !== false && item.cardData.buttonText} 54 + {#if item.cardData.buttonHref === '#login'} 55 + <button 56 + type="button" 57 + onclick={() => { 58 + if (user.isLoggedIn && user.profile) { 59 + goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 60 + } else { 61 + atProtoLoginModalState.show(); 62 + } 63 + }} 64 + class={cn(buttonClass, 'pointer-events-auto')} 65 + > 66 + {item.cardData.buttonText} 67 + </button> 68 + {:else} 69 + <a 70 + href={item.cardData.buttonHref || '#'} 71 + target={item.cardData.buttonHref ? '_blank' : undefined} 72 + rel="noopener noreferrer" 73 + class={cn(buttonClass, 'pointer-events-auto')} 74 + > 75 + {item.cardData.buttonText} 76 + </a> 77 + {/if} 78 + {/if} 79 + </div> 80 + </div>
+270
src/lib/cards/sections/HeroCard/HeroCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { SettingsComponentProps } from '../../types'; 4 + import { Button, Checkbox, Input, Label, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 5 + import { getDidContext } from '$lib/website/context'; 6 + import { getImage } from '$lib/helper'; 7 + import { getHeroDecorations, type HeroDecoration } from '.'; 8 + 9 + let { item = $bindable<Item>(), onclose }: SettingsComponentProps = $props(); 10 + 11 + function confirmUrl() { 12 + let href = item.cardData.buttonHref?.trim() || ''; 13 + if (href && !/^https?:\/\//i.test(href) && !href.startsWith('#')) { 14 + href = 'https://' + href; 15 + } 16 + item.cardData.buttonHref = href; 17 + onclose(); 18 + } 19 + 20 + const toggleClasses = 'size-8 min-w-8 [&_svg]:size-3 cursor-pointer'; 21 + 22 + const did = getDidContext(); 23 + let allSlots = $derived(getHeroDecorations(item.cardData)); 24 + let filledSlots = $derived(allSlots.filter((d) => d.image)); 25 + 26 + function clearSlot(id: string) { 27 + const stored = (item.cardData.decorations as HeroDecoration[]) ?? []; 28 + item.cardData.decorations = stored.filter((d) => d.id !== id); 29 + } 30 + 31 + function updateSlot(id: string, patch: Partial<HeroDecoration>) { 32 + const stored = (item.cardData.decorations as HeroDecoration[]) ?? []; 33 + const idx = stored.findIndex((d) => d.id === id); 34 + const next = [...stored]; 35 + if (idx >= 0) { 36 + next[idx] = { ...next[idx], ...patch }; 37 + } else { 38 + const slot = allSlots.find((s) => s.id === id); 39 + if (!slot) return; 40 + next.push({ ...slot, ...patch }); 41 + } 42 + item.cardData.decorations = next; 43 + } 44 + </script> 45 + 46 + <div class="flex w-72 flex-col gap-3"> 47 + <div class="flex flex-col gap-2"> 48 + <Label class="text-sm">Show</Label> 49 + <div class="flex items-center space-x-2"> 50 + <Checkbox 51 + bind:checked={ 52 + () => item.cardData.showBadge !== false, (val) => (item.cardData.showBadge = val) 53 + } 54 + id="hero-show-badge" 55 + aria-labelledby="hero-show-badge-label" 56 + variant="secondary" 57 + /> 58 + <Label id="hero-show-badge-label" for="hero-show-badge" class="text-sm leading-none"> 59 + Badge 60 + </Label> 61 + </div> 62 + <div class="flex items-center space-x-2"> 63 + <Checkbox 64 + bind:checked={ 65 + () => item.cardData.showSubtitle !== false, (val) => (item.cardData.showSubtitle = val) 66 + } 67 + id="hero-show-subtitle" 68 + aria-labelledby="hero-show-subtitle-label" 69 + variant="secondary" 70 + /> 71 + <Label id="hero-show-subtitle-label" for="hero-show-subtitle" class="text-sm leading-none"> 72 + Subtitle 73 + </Label> 74 + </div> 75 + <div class="flex items-center space-x-2"> 76 + <Checkbox 77 + bind:checked={ 78 + () => item.cardData.showButton !== false, (val) => (item.cardData.showButton = val) 79 + } 80 + id="hero-show-button" 81 + aria-labelledby="hero-show-button-label" 82 + variant="secondary" 83 + /> 84 + <Label id="hero-show-button-label" for="hero-show-button" class="text-sm leading-none"> 85 + Button 86 + </Label> 87 + </div> 88 + </div> 89 + 90 + <div class="flex flex-col gap-1"> 91 + <Label for="hero-button-href" class="text-sm">Button link</Label> 92 + <Input 93 + id="hero-button-href" 94 + bind:value={item.cardData.buttonHref} 95 + placeholder="example.com" 96 + class="mt-2 text-sm" 97 + onkeydown={(event) => { 98 + if (event.code === 'Enter') { 99 + event.preventDefault(); 100 + confirmUrl(); 101 + } 102 + }} 103 + /> 104 + </div> 105 + 106 + <div class="flex flex-col gap-1"> 107 + <Label class="text-sm">Alignment</Label> 108 + <ToggleGroup 109 + type="single" 110 + bind:value={ 111 + () => { 112 + return item.cardData.textAlign ?? 'center'; 113 + }, 114 + (value) => { 115 + if (!value) return; 116 + item.cardData.textAlign = value; 117 + } 118 + } 119 + > 120 + <ToggleGroupItem size="sm" value="left" class={toggleClasses}> 121 + <svg 122 + xmlns="http://www.w3.org/2000/svg" 123 + viewBox="0 0 24 24" 124 + fill="none" 125 + stroke="currentColor" 126 + stroke-width="3" 127 + stroke-linecap="round" 128 + stroke-linejoin="round" 129 + > 130 + <path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /> 131 + </svg> 132 + </ToggleGroupItem> 133 + <ToggleGroupItem size="sm" value="center" class={toggleClasses}> 134 + <svg 135 + xmlns="http://www.w3.org/2000/svg" 136 + viewBox="0 0 24 24" 137 + fill="none" 138 + stroke="currentColor" 139 + stroke-width="3" 140 + stroke-linecap="round" 141 + stroke-linejoin="round" 142 + > 143 + <path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /> 144 + </svg> 145 + </ToggleGroupItem> 146 + <ToggleGroupItem size="sm" value="right" class={toggleClasses}> 147 + <svg 148 + xmlns="http://www.w3.org/2000/svg" 149 + viewBox="0 0 24 24" 150 + fill="none" 151 + stroke="currentColor" 152 + stroke-width="3" 153 + stroke-linecap="round" 154 + stroke-linejoin="round" 155 + > 156 + <path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /> 157 + </svg> 158 + </ToggleGroupItem> 159 + </ToggleGroup> 160 + </div> 161 + 162 + <div class="flex flex-col gap-1"> 163 + <Label class="text-sm">Vertical</Label> 164 + <ToggleGroup 165 + type="single" 166 + bind:value={ 167 + () => { 168 + return item.cardData.verticalAlign ?? 'center'; 169 + }, 170 + (value) => { 171 + if (!value) return; 172 + item.cardData.verticalAlign = value; 173 + } 174 + } 175 + > 176 + <ToggleGroupItem size="sm" value="top" class={toggleClasses}> 177 + <svg 178 + xmlns="http://www.w3.org/2000/svg" 179 + viewBox="0 0 24 24" 180 + fill="none" 181 + stroke="currentColor" 182 + stroke-width="3" 183 + stroke-linecap="round" 184 + stroke-linejoin="round" 185 + > 186 + <rect width="6" height="16" x="4" y="6" rx="2" /> 187 + <rect width="6" height="9" x="14" y="6" rx="2" /> 188 + <path d="M22 2H2" /> 189 + </svg> 190 + </ToggleGroupItem> 191 + <ToggleGroupItem size="sm" value="center" class={toggleClasses}> 192 + <svg 193 + xmlns="http://www.w3.org/2000/svg" 194 + viewBox="0 0 24 24" 195 + fill="none" 196 + stroke="currentColor" 197 + stroke-width="3" 198 + stroke-linecap="round" 199 + stroke-linejoin="round" 200 + > 201 + <rect width="10" height="6" x="7" y="9" rx="2" /> 202 + <path d="M22 20H2" /> 203 + <path d="M22 4H2" /> 204 + </svg> 205 + </ToggleGroupItem> 206 + <ToggleGroupItem size="sm" value="bottom" class={toggleClasses}> 207 + <svg 208 + xmlns="http://www.w3.org/2000/svg" 209 + viewBox="0 0 24 24" 210 + fill="none" 211 + stroke="currentColor" 212 + stroke-width="3" 213 + stroke-linecap="round" 214 + stroke-linejoin="round" 215 + > 216 + <rect width="14" height="6" x="5" y="12" rx="2" /> 217 + <rect width="10" height="6" x="7" y="2" rx="2" /> 218 + <path d="M2 22h20" /> 219 + </svg> 220 + </ToggleGroupItem> 221 + </ToggleGroup> 222 + </div> 223 + 224 + <div class="border-base-200 dark:border-base-800 flex flex-col gap-2 border-t pt-3"> 225 + <Label class="text-sm">Decorations</Label> 226 + 227 + {#if filledSlots.length === 0} 228 + <p class="text-base-500 dark:text-base-400 text-xs"> 229 + Hover the hero card and click an empty slot to add a side image. Edit titles & subtitles 230 + here once filled. 231 + </p> 232 + {/if} 233 + 234 + {#each filledSlots as decoration (decoration.id)} 235 + <div 236 + class="border-base-200 dark:border-base-800 flex items-start gap-2 rounded-xl border p-2" 237 + > 238 + <img 239 + src={getImage(decoration as Record<string, any>, did, 'image')} 240 + alt="" 241 + class="size-12 shrink-0 rounded-lg object-cover" 242 + /> 243 + <div class="flex flex-1 flex-col gap-1"> 244 + <Input 245 + placeholder="Title" 246 + value={decoration.title ?? ''} 247 + oninput={(e) => 248 + updateSlot(decoration.id, { title: (e.target as HTMLInputElement).value })} 249 + class="text-xs" 250 + /> 251 + <Input 252 + placeholder="Subtitle" 253 + value={decoration.subtitle ?? ''} 254 + oninput={(e) => 255 + updateSlot(decoration.id, { subtitle: (e.target as HTMLInputElement).value })} 256 + class="text-xs" 257 + /> 258 + <Button 259 + size="sm" 260 + variant="ghost" 261 + onclick={() => clearSlot(decoration.id)} 262 + class="self-start text-xs" 263 + > 264 + Remove 265 + </Button> 266 + </div> 267 + </div> 268 + {/each} 269 + </div> 270 + </div>
+98
src/lib/cards/sections/HeroCard/index.ts
··· 1 + import { COLUMNS } from '$lib'; 2 + import { checkAndUploadImage } from '$lib/helper'; 3 + import type { CardDefinition } from '../../types'; 4 + import EditingHeroCard from './EditingHeroCard.svelte'; 5 + import HeroCard from './HeroCard.svelte'; 6 + import HeroCardSettings from './HeroCardSettings.svelte'; 7 + 8 + export type HeroDecoration = { 9 + id: string; 10 + side: 'left' | 'right'; 11 + top: number; 12 + rotation: number; 13 + image?: any; 14 + title?: string; 15 + subtitle?: string; 16 + }; 17 + 18 + export const DEFAULT_DECORATION_SLOTS: HeroDecoration[] = [ 19 + { id: 'slot-l-0', side: 'left', top: 8, rotation: -10 }, 20 + { id: 'slot-l-1', side: 'left', top: 32, rotation: -4 }, 21 + { id: 'slot-l-2', side: 'left', top: 58, rotation: -12 }, 22 + { id: 'slot-l-3', side: 'left', top: 80, rotation: -6 }, 23 + { id: 'slot-r-0', side: 'right', top: 10, rotation: 8 }, 24 + { id: 'slot-r-1', side: 'right', top: 34, rotation: 12 }, 25 + { id: 'slot-r-2', side: 'right', top: 60, rotation: 5 }, 26 + { id: 'slot-r-3', side: 'right', top: 82, rotation: 11 } 27 + ]; 28 + 29 + export function getHeroDecorations(cardData: Record<string, any>): HeroDecoration[] { 30 + const stored = (cardData.decorations as HeroDecoration[]) ?? []; 31 + return DEFAULT_DECORATION_SLOTS.map((slot) => { 32 + const found = stored.find((d) => d.id === slot.id); 33 + return found ? { ...slot, ...found } : slot; 34 + }); 35 + } 36 + 37 + export const HeroCardDefinition = { 38 + type: 'hero', 39 + contentComponent: HeroCard, 40 + editingContentComponent: EditingHeroCard, 41 + settingsComponent: HeroCardSettings, 42 + upload: async (item) => { 43 + const decorations = (item.cardData.decorations as HeroDecoration[]) ?? []; 44 + for (const decoration of decorations) { 45 + await checkAndUploadImage(decoration as Record<string, any>, 'image'); 46 + } 47 + return item; 48 + }, 49 + createNew: (card) => { 50 + card.cardType = 'hero'; 51 + card.cardData = { 52 + title: 'My cool website', 53 + subtitle: 'A little something about me, what I do, and why you should stick around.', 54 + badge: 'Welcome', 55 + buttonText: 'Say hi', 56 + buttonHref: '', 57 + showBadge: true, 58 + showSubtitle: true, 59 + showButton: true, 60 + textAlign: 'center', 61 + verticalAlign: 'center', 62 + decorations: [] 63 + }; 64 + 65 + card.w = COLUMNS; 66 + card.h = 7; 67 + card.mobileW = COLUMNS; 68 + card.mobileH = 10; 69 + }, 70 + 71 + defaultColor: 'transparent', 72 + allowSetColor: true, 73 + canHaveLabel: false, 74 + noOverflow: true, 75 + 76 + minW: COLUMNS, 77 + minH: 4, 78 + maxW: COLUMNS, 79 + maxH: 16, 80 + 81 + name: 'Hero', 82 + keywords: ['hero', 'header', 'landing', 'banner', 'intro', 'title', 'cta'], 83 + groups: ['Sections'], 84 + 85 + 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>` 86 + } as CardDefinition & { type: 'hero' }; 87 + 88 + export const heroAlignClasses: Record<string, string> = { 89 + left: 'text-left items-start', 90 + center: 'text-center items-center', 91 + right: 'text-right items-end' 92 + }; 93 + 94 + export const heroVerticalAlignClasses: Record<string, string> = { 95 + top: 'justify-start', 96 + center: 'justify-center', 97 + bottom: 'justify-end' 98 + };
+3
src/lib/cards/types.ts
··· 80 80 81 81 canResize?: boolean; 82 82 83 + // if true, content can render outside the card's bounds (no overflow:hidden on the inner wrapper) 84 + noOverflow?: boolean; 85 + 83 86 canAdd?: (context: { collections: string[] }) => boolean; 84 87 85 88 onUrlHandler?: (url: string, item: Item) => Item | null;
+8 -2
src/lib/components/card-command/CardCommand.svelte
··· 8 8 let { 9 9 open = $bindable(false), 10 10 onselect, 11 - onlink 11 + onlink, 12 + filter 12 13 }: { 13 14 open: boolean; 14 15 onselect: (cardDef: CardDefinition) => void; 15 16 onlink?: (url: string, cardDef: CardDefinition) => void; 17 + filter?: (cardDef: CardDefinition) => boolean; 16 18 } = $props(); 17 19 18 20 let collections = $state<string[]>([]); ··· 31 33 }); 32 34 33 35 let filteredCardDefs = $derived( 34 - AllCardDefinitions.filter((d) => !d.canAdd || d.canAdd({ collections })) 36 + AllCardDefinitions.filter( 37 + (d) => (!d.canAdd || d.canAdd({ collections })) && (!filter || filter(d)) 38 + ) 35 39 ); 40 + 41 + $inspect(filteredCardDefs, 'filteredCardDefs'); 36 42 37 43 let cardDefGroups = $derived([ 38 44 'Core',
+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
+166
src/lib/sections/GridSection/EditingGridSection.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { EditingSectionContentProps } from '../types'; 4 + import { EditableGrid, fixCollisions, compactItems, setPositionOfNewItem } from '$lib/layout'; 5 + import GridBaseEditingCard from '$lib/cards/_base/BaseCard/GridBaseEditingCard.svelte'; 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'; 9 + import { positionItemAtGridPos } from './add-item'; 10 + 11 + let { 12 + section, 13 + items = $bindable(), 14 + isMobile, 15 + selectedCardId, 16 + isCoarse, 17 + isActive, 18 + onlayoutchange, 19 + ondeselect, 20 + onrequestaddcard, 21 + oncreatefilecards, 22 + onactivate, 23 + onrefchange 24 + }: EditingSectionContentProps = $props(); 25 + 26 + let gridRef: HTMLDivElement | undefined = $state(); 27 + 28 + let sectionItems = $derived(items.filter((i) => i.sectionId === section.id)); 29 + 30 + let hovered = $state(false); 31 + const def = $derived(SectionDefinitionsByType[section.sectionType]); 32 + 33 + $effect(() => { 34 + onrefchange(gridRef); 35 + return () => onrefchange(undefined); 36 + }); 37 + 38 + $effect(() => { 39 + const el = gridRef; 40 + if (!el) return; 41 + 42 + const enter = () => (hovered = true); 43 + const leave = () => (hovered = false); 44 + const down = () => onactivate(); 45 + 46 + el.addEventListener('pointerenter', enter); 47 + el.addEventListener('pointerleave', leave); 48 + el.addEventListener('pointerdown', down); 49 + 50 + return () => { 51 + el.removeEventListener('pointerenter', enter); 52 + el.removeEventListener('pointerleave', leave); 53 + el.removeEventListener('pointerdown', down); 54 + }; 55 + }); 56 + 57 + async function handleFileDrop(files: File[], gridX: number, gridY: number) { 58 + const cards = await oncreatefilecards(files); 59 + for (let i = 0; i < cards.length; i++) { 60 + const card = cards[i]; 61 + card.sectionId = section.id; 62 + if (i === 0) { 63 + positionItemAtGridPos(card, gridX, gridY, isMobile); 64 + } else { 65 + const currentSectionItems = items.filter((it) => it.sectionId === section.id); 66 + setPositionOfNewItem(card, currentSectionItems); 67 + } 68 + 69 + items = [...items, card]; 70 + const updatedSectionItems = items.filter((it) => it.sectionId === section.id); 71 + fixCollisions(updatedSectionItems, card, isMobile); 72 + fixCollisions(updatedSectionItems, card, !isMobile); 73 + } 74 + onlayoutchange(); 75 + } 76 + </script> 77 + 78 + <EditableGrid 79 + items={sectionItems} 80 + bind:ref={gridRef} 81 + {isMobile} 82 + {selectedCardId} 83 + {isCoarse} 84 + {onlayoutchange} 85 + {ondeselect} 86 + onfiledrop={handleFileDrop} 87 + > 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} 106 + 107 + {#if sectionItems.length === 0} 108 + <div 109 + 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" 110 + > 111 + <button 112 + type="button" 113 + class="text-base-400 dark:text-base-500 hover:text-accent-500 flex cursor-pointer items-center gap-2 text-sm transition-colors" 114 + onclick={() => onrequestaddcard()} 115 + > 116 + <svg 117 + xmlns="http://www.w3.org/2000/svg" 118 + viewBox="0 0 24 24" 119 + fill="none" 120 + stroke="currentColor" 121 + stroke-width="2" 122 + stroke-linecap="round" 123 + stroke-linejoin="round" 124 + class="size-4"><path d="M12 5v14" /><path d="M5 12h14" /></svg 125 + > 126 + Add a card 127 + </button> 128 + </div> 129 + {/if} 130 + 131 + {#each sectionItems as item (item.id)} 132 + {@const idx = items.indexOf(item)} 133 + <GridBaseEditingCard 134 + bind:item={items[idx]} 135 + ondelete={() => { 136 + items = items.filter((it) => it !== item); 137 + compactItems( 138 + items.filter((i) => i.sectionId === section.id), 139 + false 140 + ); 141 + compactItems( 142 + items.filter((i) => i.sectionId === section.id), 143 + true 144 + ); 145 + onlayoutchange(); 146 + }} 147 + onsetsize={(newW, newH) => { 148 + if (isMobile) { 149 + item.mobileW = newW; 150 + item.mobileH = newH; 151 + } else { 152 + item.w = newW; 153 + item.h = newH; 154 + } 155 + fixCollisions( 156 + items.filter((i) => i.sectionId === section.id), 157 + item, 158 + isMobile 159 + ); 160 + onlayoutchange(); 161 + }} 162 + > 163 + <EditingCard bind:item={items[idx]} /> 164 + </GridBaseEditingCard> 165 + {/each} 166 + </EditableGrid>
+24
src/lib/sections/GridSection/GridSection.svelte
··· 1 + <script lang="ts"> 2 + import type { SectionContentProps } from '../types'; 3 + import GridBaseCard from '$lib/cards/_base/BaseCard/GridBaseCard.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 + <GridBaseCard {item}> 20 + <Card {item} /> 21 + </GridBaseCard> 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 + };
+93
src/lib/sections/HeroSection/Decoration.svelte
··· 1 + <script lang="ts"> 2 + import type { DecorationSlot } from '.'; 3 + import type { Item } from '$lib/types'; 4 + import Card from '$lib/cards/_base/Card/Card.svelte'; 5 + import EditingCard from '$lib/cards/_base/Card/EditingCard.svelte'; 6 + import BaseCard from '$lib/cards/_base/BaseCard/BaseCard.svelte'; 7 + import BaseEditingCard from '$lib/cards/_base/BaseCard/BaseEditingCard.svelte'; 8 + import { getSelectedCardId } from '$lib/website/context'; 9 + 10 + let { 11 + slot, 12 + item = $bindable(), 13 + isEditing = false, 14 + ondelete, 15 + onclick 16 + }: { 17 + slot: DecorationSlot; 18 + item?: Item; 19 + isEditing?: boolean; 20 + ondelete?: () => void; 21 + onclick?: () => void; 22 + } = $props(); 23 + 24 + const selectedCardId = getSelectedCardId(); 25 + let isSelected = $derived(!!item && selectedCardId?.() === item.id); 26 + 27 + const sideStyle = $derived( 28 + 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 + ); 32 + </script> 33 + 34 + {#if item && isEditing} 35 + <div 36 + class={[ 37 + 'deco absolute block w-36 text-left transition-all @[42rem]/grid:w-40', 38 + isSelected ? 'z-20' : 'z-0' 39 + ]} 40 + style="top: {(slot.top ?? 50) + '%'}; {sideStyle}" 41 + > 42 + <div class="aspect-square w-full"> 43 + <BaseEditingCard bind:item ondelete={() => ondelete?.()} showGridControls={false}> 44 + <EditingCard bind:item /> 45 + </BaseEditingCard> 46 + </div> 47 + </div> 48 + {:else if item} 49 + <div 50 + class="deco absolute z-0 block w-36 overflow-hidden rounded-3xl shadow-2xl @[42rem]/grid:w-40" 51 + style="top: {(slot.top ?? 50) + '%'}; {sideStyle}" 52 + > 53 + <div class="pointer-events-none aspect-square w-full overflow-hidden rounded-3xl"> 54 + <BaseCard {item}> 55 + <Card {item} /> 56 + </BaseCard> 57 + </div> 58 + </div> 59 + {:else if isEditing} 60 + <button 61 + type="button" 62 + class="deco 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 pointer-events-auto absolute z-0 flex aspect-square w-36 cursor-pointer flex-col items-center justify-center gap-1 rounded-3xl border-2 border-dashed bg-white/40 opacity-40 backdrop-blur-sm transition-all duration-150 group-hover/hero:opacity-100 hover:scale-[1.03] hover:opacity-100 @[42rem]/grid:w-40 dark:bg-black/20" 63 + style="top: {(slot.top ?? 50) + '%'}; {sideStyle}" 64 + {onclick} 65 + aria-label="Add card" 66 + > 67 + <svg 68 + xmlns="http://www.w3.org/2000/svg" 69 + viewBox="0 0 24 24" 70 + fill="none" 71 + stroke="currentColor" 72 + stroke-width="2" 73 + stroke-linecap="round" 74 + stroke-linejoin="round" 75 + class="size-6" 76 + > 77 + <path d="M12 5v14" /> 78 + <path d="M5 12h14" /> 79 + </svg> 80 + <span class="text-xs font-medium">Add card</span> 81 + </button> 82 + {/if} 83 + 84 + <style> 85 + .deco { 86 + --tx: 65%; 87 + } 88 + @container grid (width >= 42rem) { 89 + .deco { 90 + --tx: 55%; 91 + } 92 + } 93 + </style>
+396
src/lib/sections/HeroSection/EditingHeroSection.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + Badge, 4 + Button, 5 + Checkbox, 6 + cn, 7 + Input, 8 + Label, 9 + Popover, 10 + ToggleGroup, 11 + ToggleGroupItem 12 + } from '@foxui/core'; 13 + import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 14 + import { CardDefinitionsByType } from '$lib/cards'; 15 + import type { EditingSectionContentProps } from '../types'; 16 + import { 17 + DEFAULT_DECORATION_SLOTS, 18 + canFitInSlot, 19 + getSlotAssignments, 20 + getSlotItem, 21 + heroAlignClasses, 22 + heroVerticalAlignClasses 23 + } from '.'; 24 + import Decoration from './Decoration.svelte'; 25 + 26 + let { 27 + section, 28 + items = $bindable(), 29 + isMobile, 30 + isActive, 31 + onlayoutchange, 32 + onrequestaddcard, 33 + onactivate, 34 + onrefchange 35 + }: EditingSectionContentProps = $props(); 36 + 37 + let d = $derived(section.sectionData); 38 + let align = $derived((d.textAlign as string) ?? 'center'); 39 + let vAlign = $derived((d.verticalAlign as string) ?? 'center'); 40 + let assignments = $derived(getSlotAssignments(d)); 41 + let sectionItems = $derived(items.filter((i) => i.sectionId === section.id)); 42 + 43 + let containerRef: HTMLDivElement | undefined = $state(); 44 + let hovered = $state(false); 45 + let settingsOpen = $state(false); 46 + 47 + $effect(() => { 48 + onrefchange(containerRef); 49 + return () => onrefchange(undefined); 50 + }); 51 + 52 + $effect(() => { 53 + const el = containerRef; 54 + if (!el) return; 55 + 56 + const enter = () => (hovered = true); 57 + const leave = () => (hovered = false); 58 + const down = () => onactivate(); 59 + 60 + el.addEventListener('pointerenter', enter); 61 + el.addEventListener('pointerleave', leave); 62 + el.addEventListener('pointerdown', down); 63 + 64 + return () => { 65 + el.removeEventListener('pointerenter', enter); 66 + el.removeEventListener('pointerleave', leave); 67 + el.removeEventListener('pointerdown', down); 68 + }; 69 + }); 70 + 71 + function update(key: string, value: any) { 72 + section.sectionData = { ...d, [key]: value }; 73 + onlayoutchange(); 74 + } 75 + 76 + function openSlotPicker(slotId: string) { 77 + onrequestaddcard({ slotId }); 78 + } 79 + 80 + $effect(() => { 81 + const itemIds = new Set(sectionItems.map((i) => i.id)); 82 + const staleSlots = Object.entries(assignments).filter(([, itemId]) => !itemIds.has(itemId)); 83 + if (staleSlots.length > 0) { 84 + const next = { ...assignments }; 85 + for (const [slotId] of staleSlots) delete next[slotId]; 86 + section.sectionData = { ...d, slotAssignments: next }; 87 + return; 88 + } 89 + 90 + const assignedItemIds = new Set(Object.values(assignments)); 91 + const unassigned = sectionItems.filter((i) => !assignedItemIds.has(i.id)); 92 + if (unassigned.length === 0) return; 93 + 94 + const newAssignments = { ...assignments }; 95 + for (const item of unassigned) { 96 + const targetSlotId = item.cardData?._slotId; 97 + if (targetSlotId) delete item.cardData._slotId; 98 + 99 + if (targetSlotId && !newAssignments[targetSlotId]) { 100 + newAssignments[targetSlotId] = item.id; 101 + } else { 102 + const freeSlot = DEFAULT_DECORATION_SLOTS.find((s) => !newAssignments[s.id]); 103 + if (freeSlot) { 104 + newAssignments[freeSlot.id] = item.id; 105 + } 106 + } 107 + } 108 + section.sectionData = { ...d, slotAssignments: newAssignments }; 109 + }); 110 + 111 + function clearSlot(slotId: string) { 112 + const itemId = assignments[slotId]; 113 + if (itemId) { 114 + items = items.filter((i) => i.id !== itemId); 115 + } 116 + const next = { ...assignments }; 117 + delete next[slotId]; 118 + update('slotAssignments', next); 119 + } 120 + 121 + const toggleClasses = 'size-8 min-w-8 [&_svg]:size-3 cursor-pointer'; 122 + 123 + function confirmUrl() { 124 + let href = (d.buttonHref as string)?.trim() || ''; 125 + if (href && !/^https?:\/\//i.test(href) && !href.startsWith('#')) { 126 + href = 'https://' + href; 127 + } 128 + update('buttonHref', href); 129 + } 130 + 131 + let filledSlots = $derived( 132 + DEFAULT_DECORATION_SLOTS.filter((slot) => assignments[slot.id]) 133 + .map((slot) => ({ 134 + slot, 135 + item: getSlotItem(slot, assignments, sectionItems) 136 + })) 137 + .filter((s) => s.item) 138 + ); 139 + </script> 140 + 141 + <div 142 + bind:this={containerRef} 143 + 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 + > 145 + {#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 + <div class="pointer-events-auto absolute -top-3 right-4 z-40"> 174 + <Popover bind:open={settingsOpen}> 175 + {#snippet child({ props })} 176 + <button 177 + {...props} 178 + class="bg-base-100/80 dark:bg-base-900/80 hover:bg-base-200/80 dark:hover:bg-base-800/80 cursor-pointer rounded-full p-1 px-2 text-xs backdrop-blur-sm" 179 + > 180 + <svg 181 + xmlns="http://www.w3.org/2000/svg" 182 + viewBox="0 0 24 24" 183 + fill="none" 184 + stroke="currentColor" 185 + stroke-width="2" 186 + stroke-linecap="round" 187 + stroke-linejoin="round" 188 + class="size-3.5" 189 + > 190 + <path 191 + d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" 192 + /> 193 + <circle cx="12" cy="12" r="3" /> 194 + </svg> 195 + </button> 196 + {/snippet} 197 + <div class="flex w-72 flex-col gap-3"> 198 + <div class="flex flex-col gap-2"> 199 + <Label class="text-sm">Show</Label> 200 + <div class="flex items-center space-x-2"> 201 + <Checkbox 202 + bind:checked={() => d.showBadge !== false, (val) => update('showBadge', val)} 203 + id="hero-show-badge" 204 + variant="secondary" 205 + /> 206 + <Label for="hero-show-badge" class="text-sm leading-none">Badge</Label> 207 + </div> 208 + <div class="flex items-center space-x-2"> 209 + <Checkbox 210 + bind:checked={() => d.showSubtitle !== false, (val) => update('showSubtitle', val)} 211 + id="hero-show-subtitle" 212 + variant="secondary" 213 + /> 214 + <Label for="hero-show-subtitle" class="text-sm leading-none">Subtitle</Label> 215 + </div> 216 + <div class="flex items-center space-x-2"> 217 + <Checkbox 218 + bind:checked={() => d.showButton !== false, (val) => update('showButton', val)} 219 + id="hero-show-button" 220 + variant="secondary" 221 + /> 222 + <Label for="hero-show-button" class="text-sm leading-none">Button</Label> 223 + </div> 224 + </div> 225 + 226 + <div class="flex flex-col gap-1"> 227 + <Label for="hero-button-href" class="text-sm">Button link</Label> 228 + <Input 229 + id="hero-button-href" 230 + value={d.buttonHref ?? ''} 231 + oninput={(e) => update('buttonHref', (e.target as HTMLInputElement).value)} 232 + placeholder="example.com" 233 + class="text-sm" 234 + onkeydown={(event) => { 235 + if (event.code === 'Enter') { 236 + event.preventDefault(); 237 + confirmUrl(); 238 + } 239 + }} 240 + /> 241 + </div> 242 + 243 + <div class="flex flex-col gap-1"> 244 + <Label class="text-sm">Alignment</Label> 245 + <ToggleGroup 246 + type="single" 247 + bind:value={ 248 + () => d.textAlign ?? 'center', 249 + (value) => { 250 + if (value) update('textAlign', value); 251 + } 252 + } 253 + > 254 + <ToggleGroupItem size="sm" value="left" class={toggleClasses}> 255 + <svg 256 + xmlns="http://www.w3.org/2000/svg" 257 + viewBox="0 0 24 24" 258 + fill="none" 259 + stroke="currentColor" 260 + stroke-width="3" 261 + stroke-linecap="round" 262 + stroke-linejoin="round" 263 + ><path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /></svg 264 + > 265 + </ToggleGroupItem> 266 + <ToggleGroupItem size="sm" value="center" class={toggleClasses}> 267 + <svg 268 + xmlns="http://www.w3.org/2000/svg" 269 + viewBox="0 0 24 24" 270 + fill="none" 271 + stroke="currentColor" 272 + stroke-width="3" 273 + stroke-linecap="round" 274 + stroke-linejoin="round" 275 + ><path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /></svg 276 + > 277 + </ToggleGroupItem> 278 + <ToggleGroupItem size="sm" value="right" class={toggleClasses}> 279 + <svg 280 + xmlns="http://www.w3.org/2000/svg" 281 + viewBox="0 0 24 24" 282 + fill="none" 283 + stroke="currentColor" 284 + stroke-width="3" 285 + stroke-linecap="round" 286 + stroke-linejoin="round" 287 + ><path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /></svg 288 + > 289 + </ToggleGroupItem> 290 + </ToggleGroup> 291 + </div> 292 + 293 + {#if filledSlots.length > 0} 294 + <div class="border-base-200 dark:border-base-800 flex flex-col gap-2 border-t pt-3"> 295 + <Label class="text-sm">Slot cards</Label> 296 + {#each filledSlots as { slot, item: slotItem } (slot.id)} 297 + <div 298 + class="border-base-200 dark:border-base-800 flex items-center gap-2 rounded-xl border p-2" 299 + > 300 + <span class="text-base-500 text-xs"> 301 + {slot.side === 'left' ? 'L' : 'R'}{DEFAULT_DECORATION_SLOTS.filter( 302 + (s) => s.side === slot.side 303 + ).indexOf(slot) + 1} 304 + </span> 305 + <span class="flex-1 truncate text-xs"> 306 + {CardDefinitionsByType[slotItem?.cardType ?? '']?.name ?? slotItem?.cardType} 307 + </span> 308 + <Button 309 + size="sm" 310 + variant="ghost" 311 + onclick={() => clearSlot(slot.id)} 312 + class="text-xs text-rose-500" 313 + > 314 + Remove 315 + </Button> 316 + </div> 317 + {/each} 318 + </div> 319 + {/if} 320 + </div> 321 + </Popover> 322 + </div> 323 + {/if} 324 + 325 + <div class="group/hero relative flex flex-1 flex-col"> 326 + {#each DEFAULT_DECORATION_SLOTS as slot (slot.id)} 327 + {@const slotItem = getSlotItem(slot, assignments, sectionItems)} 328 + <Decoration 329 + {slot} 330 + item={slotItem} 331 + isEditing 332 + ondelete={() => clearSlot(slot.id)} 333 + onclick={() => openSlotPicker(slot.id)} 334 + /> 335 + {/each} 336 + 337 + <div 338 + class={cn( 339 + 'pointer-events-none relative z-10 flex w-full flex-1 flex-col gap-4 px-8 py-10 sm:px-12', 340 + heroAlignClasses[align], 341 + heroVerticalAlignClasses[vAlign] 342 + )} 343 + > 344 + {#if d.showBadge !== false} 345 + <Badge size="md" variant="primary"> 346 + <PlainTextEditor 347 + contentDict={d} 348 + key="badge" 349 + placeholder="Badge" 350 + class="min-w-[2rem]" 351 + onupdate={(text) => update('badge', text)} 352 + /> 353 + </Badge> 354 + {/if} 355 + 356 + <PlainTextEditor 357 + contentDict={d} 358 + key="title" 359 + placeholder="My cool website" 360 + class="text-base-950 dark:text-base-50 accent:text-accent-950 w-full text-4xl font-bold tracking-tight text-balance sm:text-4xl md:text-5xl" 361 + onupdate={(text) => update('title', text)} 362 + /> 363 + 364 + {#if d.showSubtitle !== false} 365 + <PlainTextEditor 366 + contentDict={d} 367 + key="subtitle" 368 + placeholder="Subtitle" 369 + class="text-base-600 dark:text-base-300 accent:text-accent-900/80 w-full max-w-2xl text-lg text-pretty sm:text-xl" 370 + onupdate={(text) => update('subtitle', text)} 371 + /> 372 + {/if} 373 + 374 + {#if d.showButton !== false} 375 + <div 376 + class={cn( 377 + 'mt-2 flex w-full', 378 + align === 'center' 379 + ? 'justify-center' 380 + : align === 'right' 381 + ? 'justify-end' 382 + : 'justify-start' 383 + )} 384 + > 385 + <PlainTextEditor 386 + contentDict={d} 387 + key="buttonText" 388 + placeholder="Button text" 389 + class="text-base-950 dark:text-base-50 accent:text-base-950 bg-accent-400 dark:bg-accent-500 inline-flex min-h-[1.5em] min-w-[6rem] items-center justify-center rounded-2xl px-6 py-3 text-xl font-semibold" 390 + onupdate={(text) => update('buttonText', text)} 391 + /> 392 + </div> 393 + {/if} 394 + </div> 395 + </div> 396 + </div>
+92
src/lib/sections/HeroSection/HeroSection.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { user } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 5 + import { atProtoLoginModalState } from '@foxui/social'; 6 + import { Badge, cn } from '@foxui/core'; 7 + import type { SectionContentProps } from '../types'; 8 + import { 9 + DEFAULT_DECORATION_SLOTS, 10 + getSlotAssignments, 11 + getSlotItem, 12 + heroAlignClasses, 13 + heroVerticalAlignClasses 14 + } from '.'; 15 + import Decoration from './Decoration.svelte'; 16 + 17 + let { section, items }: SectionContentProps = $props(); 18 + 19 + let d = $derived(section.sectionData); 20 + let align = $derived((d.textAlign as string) ?? 'center'); 21 + let vAlign = $derived((d.verticalAlign as string) ?? 'center'); 22 + let assignments = $derived(getSlotAssignments(d)); 23 + let sectionItems = $derived(items.filter((i) => i.sectionId === section.id)); 24 + 25 + const buttonClass = 26 + 'text-base-950 dark:text-base-50 accent:text-base-950 mt-2 inline-flex cursor-pointer items-center justify-center rounded-2xl bg-accent-400 dark:bg-accent-500 px-6 py-3 text-xl font-semibold transition-colors duration-100 hover:bg-accent-400'; 27 + </script> 28 + 29 + <div 30 + class="@container/grid relative col-span-3 flex min-h-[calc(100dvh-4rem)] flex-col overflow-visible px-2 py-10 lg:px-8" 31 + > 32 + <div class="relative flex flex-1 flex-col"> 33 + {#each DEFAULT_DECORATION_SLOTS as slot (slot.id)} 34 + <Decoration {slot} item={getSlotItem(slot, assignments, sectionItems)} /> 35 + {/each} 36 + 37 + <div 38 + class={cn( 39 + 'pointer-events-none relative z-10 flex w-full flex-1 flex-col gap-4 px-8 py-10 sm:px-12', 40 + heroAlignClasses[align], 41 + heroVerticalAlignClasses[vAlign] 42 + )} 43 + > 44 + {#if d.showBadge !== false && d.badge} 45 + <Badge size="md" variant="primary"> 46 + {d.badge} 47 + </Badge> 48 + {/if} 49 + 50 + <h1 51 + class="text-base-950 dark:text-base-50 accent:text-accent-950 text-4xl font-bold tracking-tight text-balance sm:text-5xl md:text-6xl" 52 + > 53 + {d.title || 'My cool website'} 54 + </h1> 55 + 56 + {#if d.showSubtitle !== false && d.subtitle} 57 + <p 58 + class="text-base-600 dark:text-base-300 accent:text-accent-900/80 max-w-2xl text-lg text-pretty sm:text-xl" 59 + > 60 + {d.subtitle} 61 + </p> 62 + {/if} 63 + 64 + {#if d.showButton !== false && d.buttonText} 65 + {#if d.buttonHref === '#login'} 66 + <button 67 + type="button" 68 + onclick={() => { 69 + if (user.isLoggedIn && user.profile) { 70 + goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 71 + } else { 72 + atProtoLoginModalState.show(); 73 + } 74 + }} 75 + class={cn(buttonClass, 'pointer-events-auto')} 76 + > 77 + {d.buttonText} 78 + </button> 79 + {:else} 80 + <a 81 + href={d.buttonHref || '#'} 82 + target={d.buttonHref ? '_blank' : undefined} 83 + rel="noopener noreferrer" 84 + class={cn(buttonClass, 'pointer-events-auto')} 85 + > 86 + {d.buttonText} 87 + </a> 88 + {/if} 89 + {/if} 90 + </div> 91 + </div> 92 + </div>
+90
src/lib/sections/HeroSection/index.ts
··· 1 + import type { Item, SectionRecord } from '$lib/types'; 2 + import type { SectionDefinition } from '../types'; 3 + import EditingHeroSection from './EditingHeroSection.svelte'; 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 + } 35 + 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 + }; 67 + 68 + export const HeroSectionDefinition: SectionDefinition = { 69 + type: 'hero', 70 + contentComponent: HeroSection, 71 + editingContentComponent: EditingHeroSection, 72 + defaultSectionData: defaultHeroSectionData, 73 + cardFilter: canFitInSlot, 74 + addItem: (item: Item, allItems: Item[], options) => { 75 + item.w = 2; 76 + item.h = 2; 77 + item.mobileW = 2; 78 + item.mobileH = 2; 79 + if (options.extraData?.slotId) { 80 + item.cardData._slotId = options.extraData.slotId; 81 + } 82 + return [...allItems, item]; 83 + }, 84 + deleteItem: (itemId: string, allItems: Item[]) => { 85 + return allItems.filter((i) => i.id !== itemId); 86 + }, 87 + resizeItem: () => {}, 88 + name: 'Hero', 89 + 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>` 90 + };
+10
src/lib/sections/feature-flag.ts
··· 1 + import { env } from '$env/dynamic/public'; 2 + 3 + /** 4 + * Controls whether the section management UI is exposed to users. 5 + * When false, users can only edit the default grid section — no adding, 6 + * reordering, or deleting sections (or hero/other section types). 7 + * 8 + * Set PUBLIC_SECTIONS_ENABLED=true in the environment to enable. 9 + */ 10 + export const SECTIONS_EDITING_ENABLED = env.PUBLIC_SECTIONS_ENABLED === 'true';
+13
src/lib/sections/index.ts
··· 1 + import type { SectionDefinition } from './types'; 2 + import { GridSectionDefinition } from './GridSection'; 3 + import { HeroSectionDefinition } from './HeroSection'; 4 + 5 + export const AllSectionDefinitions = [GridSectionDefinition, HeroSectionDefinition] as const; 6 + 7 + export const SectionDefinitionsByType = AllSectionDefinitions.reduce( 8 + (acc, def) => { 9 + acc[def.type] = def; 10 + return acc; 11 + }, 12 + {} as Record<string, SectionDefinition> 13 + );
+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 + }
+40
src/lib/sections/types.ts
··· 1 + import type { Component } from 'svelte'; 2 + import type { Item, SectionRecord } from '$lib/types'; 3 + import type { CardDefinition } from '$lib/cards/types'; 4 + 5 + export type SectionContentProps = { 6 + section: SectionRecord; 7 + items: Item[]; 8 + isMobile: boolean; 9 + }; 10 + 11 + export type EditingSectionContentProps = SectionContentProps & { 12 + selectedCardId: string | null; 13 + isCoarse: boolean; 14 + isActive: boolean; 15 + onlayoutchange: () => void; 16 + ondeselect: () => void; 17 + onrequestaddcard: (extraData?: Record<string, any>) => void; 18 + oncreatefilecards: (files: File[]) => Promise<Item[]>; 19 + onactivate: () => void; 20 + onrefchange: (el: HTMLDivElement | undefined) => void; 21 + }; 22 + 23 + export type AddItemOptions = { 24 + gridRef?: HTMLDivElement; 25 + isMobile: boolean; 26 + extraData?: Record<string, any>; 27 + }; 28 + 29 + export type SectionDefinition = { 30 + type: string; 31 + contentComponent: Component<SectionContentProps>; 32 + editingContentComponent: Component<EditingSectionContentProps>; 33 + defaultSectionData?: () => Record<string, any>; 34 + cardFilter?: (cardDef: CardDefinition) => boolean; 35 + addItem: (item: Item, allItems: Item[], options: AddItemOptions) => Item[]; 36 + deleteItem: (itemId: string, allItems: Item[], sectionId: string) => Item[]; 37 + resizeItem: (item: Item, allItems: Item[], w: number, h: number, isMobile: boolean) => void; 38 + name: string; 39 + icon?: string; 40 + };
+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>
+45 -25
src/lib/website/EditBar.svelte
··· 1 1 <script lang="ts"> 2 2 import { dev } from '$app/environment'; 3 + import { page } from '$app/state'; 3 4 import { user } from '$lib/atproto'; 4 5 import { COLUMNS } from '$lib'; 5 6 import type { Item, WebsiteData } from '$lib/types'; ··· 16 17 17 18 save, 18 19 19 - handleImageInputChange, 20 - handleVideoInputChange, 20 + handleFileInputChange, 21 21 22 22 newCard, 23 23 addLink, ··· 29 29 isCoarse = false, 30 30 ondeselect, 31 31 ondelete, 32 - onsetsize 32 + onsetsize, 33 + showSectionsModal 33 34 }: { 34 35 data: WebsiteData; 35 36 ··· 40 41 41 42 save: () => Promise<void>; 42 43 43 - handleImageInputChange: (evt: Event) => void; 44 - handleVideoInputChange: (evt: Event) => void; 44 + handleFileInputChange: (evt: Event) => void; 45 45 46 46 newCard: (type?: string, cardData?: any) => void; 47 47 addLink: (url: string) => void; ··· 54 54 ondeselect?: () => void; 55 55 ondelete?: () => void; 56 56 onsetsize?: (w: number, h: number) => void; 57 + showSectionsModal?: () => void; 57 58 } = $props(); 58 59 59 60 let linkPopoverOpen = $state(false); 60 - let imageInputRef: HTMLInputElement | undefined = $state(); 61 - let videoInputRef: HTMLInputElement | undefined = $state(); 61 + let fileInputRef: HTMLInputElement | undefined = $state(); 62 62 63 63 function getShareUrl() { 64 64 const base = typeof window !== 'undefined' ? window.location.origin : ''; 65 65 const pagePath = 66 66 data.page && data.page !== 'blento.self' ? `/p/${data.page.replace('blento.', '')}` : ''; 67 - return `${base}/${data.handle}${pagePath}`; 67 + 68 + if (page.data.customDomain) { 69 + return `${base}${pagePath || '/'}`; 70 + } 71 + 72 + const handle = data.profile?.handle; 73 + const actor = handle && handle !== 'handle.invalid' ? handle : data.did; 74 + return `${base}/${actor}${pagePath}`; 68 75 } 69 76 70 77 async function copyShareLink() { ··· 123 130 return w >= minW && w <= maxW && h >= minH && h <= maxH; 124 131 } 125 132 126 - const showMobileEditControls = $derived(isCoarse && selectedCard); 133 + const showEditControls = $derived(!!selectedCard); 127 134 </script> 128 135 129 136 <input 130 137 type="file" 131 - accept="image/*" 132 - onchange={handleImageInputChange} 133 - class="hidden" 134 - id="image-input" 135 - multiple 136 - bind:this={imageInputRef} 137 - /> 138 - 139 - <input 140 - type="file" 141 - accept="video/*" 142 - onchange={handleVideoInputChange} 138 + accept="image/*,video/*" 139 + onchange={handleFileInputChange} 143 140 class="hidden" 144 - id="video-input" 141 + id="file-input" 145 142 multiple 146 - bind:this={videoInputRef} 143 + bind:this={fileInputRef} 147 144 /> 148 145 149 146 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 150 147 <Navbar 151 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" 152 149 > 153 - {#if showMobileEditControls} 150 + {#if showEditControls} 154 151 <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 155 152 <div class="flex items-center gap-1"> 156 153 {#if cardDef?.allowSetColor !== false} ··· 345 342 </Button> 346 343 </div> 347 344 {/if} 348 - <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 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} 352 + > 353 + <svg 354 + xmlns="http://www.w3.org/2000/svg" 355 + viewBox="0 0 24 24" 356 + fill="none" 357 + stroke="currentColor" 358 + stroke-width="2" 359 + stroke-linecap="round" 360 + stroke-linejoin="round" 361 + class="size-5" 362 + > 363 + <rect x="3" y="3" width="18" height="18" rx="2" /> 364 + <path d="M3 9h18" /> 365 + <path d="M3 15h18" /> 366 + </svg> 367 + </Button> 368 + {/if} 349 369 <Toggle 350 370 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 351 371 bind:pressed={showingMobileView}
+185 -359
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 { SECTIONS_EDITING_ENABLED } from '$lib/sections/feature-flag'; 42 + import type { SectionRecord } from '$lib/types'; 50 43 51 44 let { 52 45 data ··· 65 58 return `${origin}/${actor}/og-new.png`; 66 59 }); 67 60 68 - // Snapshot the original cards so savePage can detect deletions. 61 + // Snapshot the original cards and sections so savePage can detect deletions. 69 62 const originalCards: Item[] = structuredClone(data.cards); 63 + const originalSections: SectionRecord[] = structuredClone(data.sections); 70 64 71 65 // svelte-ignore state_referenced_locally 72 66 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 - } 67 + // svelte-ignore state_referenced_locally 68 + let sections: SectionRecord[] = $state(data.sections); 81 69 82 70 // svelte-ignore state_referenced_locally 83 71 let publication = $state(JSON.stringify(data.publication)); ··· 105 93 } 106 94 }); 107 95 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(); 96 + let gridRefs = new SvelteMap<string, HTMLDivElement>(); 134 97 135 98 let showingMobileView = $state(false); 136 99 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); ··· 165 128 selectedCardId = id; 166 129 }); 167 130 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)); 131 + let activeSectionId = $state(sections[0]?.id); 132 + let gridContainer = $derived( 133 + activeSectionId ? gridRefs.get(activeSectionId) : gridRefs.values().next().value 134 + ); 135 + 136 + let pendingExtraData: Record<string, any> | undefined = $state(); 137 + 138 + let activeSection = $derived(sections.find((s) => s.id === activeSectionId)); 139 + let activeSectionDef = $derived( 140 + activeSection ? SectionDefinitionsByType[activeSection.sectionType] : undefined 141 + ); 142 + 143 + function requestAddCard(extraData?: Record<string, any>) { 144 + pendingExtraData = extraData; 145 + showCardCommand = true; 146 + } 171 147 172 148 function newCard(type: string = 'link', cardData?: any) { 173 149 selectedCardId = null; 174 150 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); 151 + let item = createEmptyCard(data.page, activeSectionId); 182 152 item.cardType = type; 183 - 184 153 item.cardData = cardData ?? {}; 185 154 186 155 const cardDef = CardDefinitionsByType[type]; ··· 213 182 setTimeout(restore, 50); 214 183 } 215 184 216 - async function saveNewItem() { 217 - if (!newItem.item) return; 218 - const item = newItem.item; 185 + function addItem(item: Item, extraData?: Record<string, any>) { 186 + const ref = item.sectionId ? gridRefs.get(item.sectionId) : gridContainer; 219 187 220 - const viewportCenter = gridContainer 221 - ? getViewportCenterGridY(gridContainer, isMobile) 222 - : undefined; 223 - setPositionOfNewItem(item, items, viewportCenter); 188 + if (activeSectionDef?.addItem) { 189 + items = activeSectionDef.addItem(item, items, { gridRef: ref, isMobile, extraData }); 190 + } else { 191 + items = [...items, item]; 192 + } 224 193 225 - items = [...items, item]; 194 + onLayoutChanged(); 195 + return ref; 196 + } 226 197 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); 198 + async function createFileCards(files: File[]): Promise<Item[]> { 199 + const cards: Item[] = []; 200 + for (const file of files) { 201 + if (file.type.startsWith('video/')) { 202 + cards.push(createVideoCard(file, data.page)); 203 + } else { 204 + cards.push(await createImageCard(file, data.page)); 205 + } 206 + } 207 + return cards; 208 + } 232 209 233 - onLayoutChanged(); 210 + async function saveNewItem() { 211 + if (!newItem.item) return; 212 + const item = newItem.item; 213 + const extraData = pendingExtraData; 214 + pendingExtraData = undefined; 215 + 216 + const ref = addItem(item, extraData); 234 217 235 218 newItem = {}; 236 219 237 220 await tick(); 238 221 cleanupDialogArtifacts(); 239 222 240 - scrollToItem(item, isMobile, gridContainer); 223 + scrollToItem(item, isMobile, ref); 241 224 } 242 225 243 226 let isSaving = $state(false); ··· 261 244 data.publication.preferences ??= {}; 262 245 data.publication.preferences.editedOn = editedOn; 263 246 264 - await savePage(data, items, originalCards, publication); 247 + data.sections = sections; 248 + await savePage(data, items, originalCards, publication, originalSections); 265 249 266 250 publication = JSON.stringify(data.publication); 267 251 savedPronouns = JSON.stringify(data.pronounsRecord); ··· 281 265 } 282 266 } 283 267 268 + let showSectionsModal = $state(false); 269 + 270 + function addSection(sectionType: string, afterIndex?: number) { 271 + const sorted = sections.toSorted((a, b) => a.index - b.index); 272 + let newIndex: number; 273 + if (afterIndex !== undefined && afterIndex < sorted.length) { 274 + const curr = sorted[afterIndex].index; 275 + const next = afterIndex + 1 < sorted.length ? sorted[afterIndex + 1].index : curr + 200; 276 + newIndex = Math.floor((curr + next) / 2); 277 + } else { 278 + newIndex = (sorted[sorted.length - 1]?.index ?? 0) + 100; 279 + } 280 + 281 + const def = SectionDefinitionsByType[sectionType]; 282 + const section: SectionRecord = { 283 + id: TID.now(), 284 + sectionType, 285 + page: data.page, 286 + index: newIndex, 287 + sectionData: def?.defaultSectionData?.() ?? {}, 288 + version: 1 289 + }; 290 + sections = [...sections, section]; 291 + hasUnsavedChanges = true; 292 + } 293 + 294 + function deleteSection(id: string) { 295 + if (sections.length <= 1) return; 296 + items = items.filter((i) => i.sectionId !== id); 297 + sections = sections.filter((s) => s.id !== id); 298 + if (activeSectionId === id) { 299 + activeSectionId = sections[0]?.id; 300 + } 301 + hasUnsavedChanges = true; 302 + } 303 + 284 304 let linkValue = $state(''); 285 305 286 306 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 289 309 toast.error('invalid link'); 290 310 return; 291 311 } 292 - let item = createEmptyCard(data.page); 312 + let item = createEmptyCard(data.page, activeSectionId); 293 313 294 314 if (specificCardDef?.onUrlHandler?.(link, item)) { 295 315 item.cardType = specificCardDef.type; ··· 313 333 } 314 334 } 315 335 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) { 437 - const target = event.target as HTMLInputElement; 438 - if (!target.files || target.files.length < 1) return; 439 - 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 - } 462 - } 463 - 464 - // Reset the input so the same file can be selected again 465 - target.value = ''; 466 - } 467 - 468 - async function processVideoFile(file: File) { 469 - const objectUrl = URL.createObjectURL(file); 470 - 471 - let item = createEmptyCard(data.page); 472 - 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) { 336 + async function handleFileInputChange(event: Event) { 497 337 const target = event.target as HTMLInputElement; 498 338 if (!target.files || target.files.length < 1) return; 499 339 500 - const files = Array.from(target.files); 340 + const extraData = pendingExtraData; 341 + pendingExtraData = undefined; 501 342 502 - for (const file of files) { 503 - await processVideoFile(file); 343 + const cards = await createFileCards(Array.from(target.files)); 344 + for (const card of cards) { 345 + card.sectionId = activeSectionId; 346 + addItem(card, extraData); 504 347 } 505 348 506 - // Reset the input so the same file can be selected again 507 349 target.value = ''; 508 350 } 509 351 510 352 let showCardCommand = $state(false); 511 353 </script> 512 354 355 + <svelte:window 356 + onbeforeunload={(e) => { 357 + if (hasUnsavedChanges) { 358 + e.preventDefault(); 359 + return ''; 360 + } 361 + }} 362 + /> 363 + 513 364 <svelte:body 365 + onkeydown={(e) => { 366 + if (e.key === 'Escape' && selectedCardId) { 367 + selectedCardId = null; 368 + } 369 + }} 514 370 onpaste={(event) => { 515 371 if (isTyping()) return; 516 372 ··· 536 392 <ImageViewerProvider /> 537 393 <CardCommand 538 394 bind:open={showCardCommand} 395 + filter={activeSectionDef?.cardFilter} 539 396 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; 397 + if (cardDef.type === 'image' || cardDef.type === 'video') { 398 + const input = document.getElementById('file-input') as HTMLInputElement; 548 399 if (input) { 549 400 input.click(); 550 401 return; 551 402 } 552 - } else { 553 - newCard(cardDef.type); 554 403 } 404 + newCard(cardDef.type); 555 405 }} 556 406 onlink={(url, cardDef) => { 557 407 addLink(url, cardDef); ··· 583 433 <SaveModal 584 434 bind:open={showSaveModal} 585 435 success={saveSuccess} 586 - handle={data.handle} 436 + handle={data.profile?.handle ?? data.handle} 437 + did={data.did} 587 438 page={data.page} 588 439 /> 589 440 590 - <Modal open={showLayoutFixModal} closeButton={false}> 591 - <div class="flex flex-col items-center gap-4 text-center"> 592 - <svg 593 - xmlns="http://www.w3.org/2000/svg" 594 - fill="none" 595 - viewBox="0 0 24 24" 596 - stroke-width="1.5" 597 - stroke="currentColor" 598 - class="size-10 text-amber-500" 599 - > 600 - <path 601 - stroke-linecap="round" 602 - stroke-linejoin="round" 603 - 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" 604 - /> 605 - </svg> 606 - <p class="text-base-700 dark:text-base-300 text-xl font-bold">Layout Auto-Fixed</p> 607 - <p class="text-base-500 dark:text-base-400 text-sm"> 608 - Your card layout had overlapping cards from an older version. This has been automatically 609 - fixed, but some cards may have moved. Please check your layout and rearrange if needed, then 610 - save to keep the changes. 611 - </p> 612 - <Button class="w-full" onclick={acknowledgeLayoutFix}>Got it</Button> 613 - </div> 614 - </Modal> 441 + <SectionsModal 442 + bind:open={showSectionsModal} 443 + bind:sections 444 + ondelete={deleteSection} 445 + onlayoutchange={() => (hasUnsavedChanges = true)} 446 + /> 615 447 616 448 <Modal open={showMobileWarning} closeButton={false}> 617 449 <div class="flex flex-col items-center gap-4 text-center"> ··· 639 471 640 472 <div 641 473 class={[ 642 - '@container/wrapper relative w-full', 474 + 'group/wrapper @container/wrapper relative w-full overflow-x-hidden', 643 475 showingMobileView 644 - ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dvh-2em)] rounded-2xl lg:mx-auto lg:w-90' 476 + ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dvh-2em)] overflow-hidden rounded-2xl lg:mx-auto lg:w-90' 645 477 : '' 646 478 ]} 647 479 > ··· 658 490 ]} 659 491 > 660 492 <div class="pointer-events-none"></div> 661 - <EditableGrid 662 - bind:items 663 - bind:ref={gridContainer} 664 - {isMobile} 665 - {selectedCardId} 666 - {isCoarse} 667 - onlayoutchange={onLayoutChanged} 668 - ondeselect={() => { 669 - selectedCardId = null; 670 - }} 671 - onfiledrop={handleFileDrop} 672 - > 673 - {#each items as item, i (item.id)} 674 - <BaseEditingCard 675 - bind:item={items[i]} 676 - ondelete={() => { 677 - items = items.filter((it) => it !== item); 678 - compactItems(items, false); 679 - compactItems(items, true); 680 - onLayoutChanged(); 681 - }} 682 - onsetsize={(newW: number, newH: number) => { 683 - if (isMobile) { 684 - item.mobileW = newW; 685 - item.mobileH = newH; 686 - } else { 687 - item.w = newW; 688 - item.h = newH; 689 - } 690 - 691 - fixCollisions(items, item, isMobile); 692 - onLayoutChanged(); 693 - }} 694 - > 695 - <EditingCard bind:item={items[i]} /> 696 - </BaseEditingCard> 493 + <div class="@5xl/wrapper:col-start-2 @5xl/wrapper:-col-end-1"> 494 + {#each sections.toSorted((a, b) => a.index - b.index) as section, i (section.id)} 495 + {@const def = SectionDefinitionsByType[section.sectionType]} 496 + {#if def} 497 + <def.editingContentComponent 498 + {section} 499 + bind:items 500 + {isMobile} 501 + {selectedCardId} 502 + {isCoarse} 503 + isActive={activeSectionId === section.id} 504 + onrefchange={(el) => { 505 + if (el) gridRefs.set(section.id, el); 506 + else gridRefs.delete(section.id); 507 + }} 508 + onlayoutchange={onLayoutChanged} 509 + ondeselect={() => { 510 + selectedCardId = null; 511 + }} 512 + onrequestaddcard={(extraData) => { 513 + activeSectionId = section.id; 514 + requestAddCard(extraData); 515 + }} 516 + oncreatefilecards={createFileCards} 517 + onactivate={() => { 518 + activeSectionId = section.id; 519 + }} 520 + /> 521 + {/if} 522 + {#if SECTIONS_EDITING_ENABLED} 523 + <AddSectionButton onadd={(type) => addSection(type, i)} /> 524 + {/if} 697 525 {/each} 698 - </EditableGrid> 526 + <div class="h-20"></div> 527 + </div> 699 528 </div> 700 529 </div> 701 530 ··· 708 537 {newCard} 709 538 {addLink} 710 539 {save} 711 - {handleImageInputChange} 712 - {handleVideoInputChange} 540 + {handleFileInputChange} 713 541 showCardCommand={() => { 714 - showCardCommand = true; 542 + requestAddCard(); 715 543 }} 716 544 {selectedCard} 717 545 {isMobile} ··· 721 549 }} 722 550 ondelete={() => { 723 551 if (selectedCard) { 724 - items = items.filter((it) => it.id !== selectedCardId); 725 - compactItems(items, false); 726 - compactItems(items, true); 552 + const section = sections.find((s) => s.id === selectedCard!.sectionId); 553 + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; 554 + if (def?.deleteItem) { 555 + items = def.deleteItem(selectedCardId!, items, selectedCard.sectionId!); 556 + } else { 557 + items = items.filter((it) => it.id !== selectedCardId); 558 + } 727 559 onLayoutChanged(); 728 560 selectedCardId = null; 729 561 } 730 562 }} 731 563 onsetsize={(w: number, h: number) => { 732 564 if (selectedCard) { 733 - if (isMobile) { 734 - selectedCard.mobileW = w; 735 - selectedCard.mobileH = h; 736 - } else { 737 - selectedCard.w = w; 738 - selectedCard.h = h; 565 + const section = sections.find((s) => s.id === selectedCard!.sectionId); 566 + const def = section ? SectionDefinitionsByType[section.sectionType] : undefined; 567 + if (def?.resizeItem) { 568 + def.resizeItem(selectedCard, items, w, h, isMobile); 739 569 } 740 - fixCollisions(items, selectedCard, isMobile); 741 570 onLayoutChanged(); 742 571 } 743 572 }} 573 + showSectionsModal={SECTIONS_EDITING_ENABLED 574 + ? () => { 575 + showSectionsModal = true; 576 + } 577 + : undefined} 744 578 /> 745 579 746 580 <Toaster /> 747 581 748 582 <FloatingEditButton {data} /> 749 - 750 - {#if dev} 751 - <div 752 - 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" 753 - > 754 - <span>editedOn: {editedOn}</span> 755 - </div> 756 - {/if} 757 583 </Context>
+1 -1
src/lib/website/EmbeddedCard.svelte
··· 142 142 class="embed-content" 143 143 style={`--embed-ratio: ${cardWidth / cardHeight}; aspect-ratio: ${cardWidth} / ${cardHeight};`} 144 144 > 145 - <BaseCard item={embeddedItem} fillPage> 145 + <BaseCard item={embeddedItem}> 146 146 <Card item={embeddedItem} /> 147 147 </BaseCard> 148 148 </div>
+10 -1
src/lib/website/SaveModal.svelte
··· 1 1 <script lang="ts"> 2 2 import { Button, Modal, toast } from '@foxui/core'; 3 + import { page as pageState } from '$app/state'; 3 4 4 5 let { 5 6 open = $bindable(), 6 7 success, 7 8 handle, 9 + did, 8 10 page 9 11 }: { 10 12 open: boolean; 11 13 success: boolean; 12 14 handle: string; 15 + did?: string; 13 16 page: string; 14 17 } = $props(); 15 18 16 19 function getShareUrl() { 17 20 const base = typeof window !== 'undefined' ? window.location.origin : ''; 18 21 const pagePath = page && page !== 'blento.self' ? `/p/${page.replace('blento.', '')}` : ''; 19 - return `${base}/${handle}${pagePath}`; 22 + 23 + if (pageState.data.customDomain) { 24 + return `${base}${pagePath || '/'}`; 25 + } 26 + 27 + const actor = handle && handle !== 'handle.invalid' ? handle : (did ?? handle); 28 + return `${base}/${actor}${pagePath}`; 20 29 } 21 30 22 31 async function copyShareLink() {
+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>
+15 -18
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 ··· 77 68 <Context {data}> 78 69 <QRModalProvider /> 79 70 <ImageViewerProvider /> 80 - <div class="@container/wrapper relative w-full"> 71 + <div class="@container/wrapper relative w-full overflow-x-hidden"> 81 72 {#if !getHideProfileSection(data)} 82 73 <Profile {data} hideBlento={showFloatingButton} /> 83 74 {/if} ··· 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,
+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": {