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

Configure Feed

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

add hero section card

Florian af1efca3 8cad7bff

+684 -7
+4 -2
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 4 4 import type { WithElementRef } from 'bits-ui'; 5 5 import type { Snippet } from 'svelte'; 6 6 import type { HTMLAttributes } from 'svelte/elements'; 7 - import { getColor } from '../..'; 7 + import { CardDefinitionsByType, getColor } from '../..'; 8 8 9 9 const colors = { 10 10 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 35 35 }: BaseCardProps = $props(); 36 36 37 37 let color = $derived(getColor(item)); 38 + let noOverflow = $derived(CardDefinitionsByType[item.cardType]?.noOverflow ?? false); 38 39 </script> 39 40 40 41 <div ··· 71 72 > 72 73 <div 73 74 class={[ 74 - 'text-base-900 dark:text-base-50 relative isolate h-full w-full overflow-hidden', 75 + 'text-base-900 dark:text-base-50 relative isolate h-full w-full', 76 + noOverflow ? 'overflow-visible' : 'overflow-hidden', 75 77 !fillPage ? 'rounded-3xl' : '', 76 78 color !== 'base' && color != 'transparent' ? 'light' : '' 77 79 ]}
+3 -1
src/lib/cards/index.ts
··· 62 62 import { KichRecipeCollectionCardDefinition } from './social/KichRecipeCollectionCard'; 63 63 import { KichCookingLogCardDefinition } from './social/KichCookingLogCard'; 64 64 import { SecretImageCardDefinition } from './media/SecretImageCard'; 65 + import { HeroCardDefinition } from './sections/HeroCard'; 65 66 // import { Model3DCardDefinition } from './visual/Model3DCard'; 66 67 67 68 export const AllCardDefinitions = [ ··· 129 130 KichRecipeCardDefinition, 130 131 KichRecipeCollectionCardDefinition, 131 132 KichCookingLogCardDefinition, 132 - SecretImageCardDefinition 133 + SecretImageCardDefinition, 134 + HeroCardDefinition 133 135 ] as const; 134 136 135 137 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+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
··· 75 75 76 76 canResize?: boolean; 77 77 78 + // if true, content can render outside the card's bounds (no overflow:hidden on the inner wrapper) 79 + noOverflow?: boolean; 80 + 78 81 canAdd?: (context: { collections: string[] }) => boolean; 79 82 80 83 onUrlHandler?: (url: string, item: Item) => Item | null;
+2
src/lib/components/card-command/CardCommand.svelte
··· 34 34 AllCardDefinitions.filter((d) => !d.canAdd || d.canAdd({ collections })) 35 35 ); 36 36 37 + $inspect(filteredCardDefs, 'filteredCardDefs'); 38 + 37 39 let cardDefGroups = $derived([ 38 40 'Core', 39 41 ...Array.from(
+9 -1
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'; ··· 64 65 const base = typeof window !== 'undefined' ? window.location.origin : ''; 65 66 const pagePath = 66 67 data.page && data.page !== 'blento.self' ? `/p/${data.page.replace('blento.', '')}` : ''; 67 - return `${base}/${data.handle}${pagePath}`; 68 + 69 + if (page.data.customDomain) { 70 + return `${base}${pagePath || '/'}`; 71 + } 72 + 73 + const handle = data.profile?.handle; 74 + const actor = handle && handle !== 'handle.invalid' ? handle : data.did; 75 + return `${base}/${actor}${pagePath}`; 68 76 } 69 77 70 78 async function copyShareLink() {
+3 -2
src/lib/website/EditableWebsite.svelte
··· 583 583 <SaveModal 584 584 bind:open={showSaveModal} 585 585 success={saveSuccess} 586 - handle={data.handle} 586 + handle={data.profile?.handle ?? data.handle} 587 + did={data.did} 587 588 page={data.page} 588 589 /> 589 590 ··· 641 642 class={[ 642 643 '@container/wrapper relative w-full', 643 644 showingMobileView 644 - ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dvh-2em)] rounded-2xl lg:mx-auto lg:w-90' 645 + ? '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 646 : '' 646 647 ]} 647 648 >
+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() {