your personal website on atproto - mirror blento.app

Merge branch 'main' into profile-stuff

authored by Florian and committed by GitHub 8ae4ded2 12d5f948

+565 -45
+7 -2
src/lib/atproto/methods.ts
··· 10 10 } from '@atcute/identity-resolver'; 11 11 import { Client, simpleFetchHandler } from '@atcute/client'; 12 12 import type { AppBskyActorDefs } from '@atcute/bluesky'; 13 + import { redirect } from '@sveltejs/kit'; 13 14 14 15 export type Collection = `${string}.${string}.${string}`; 15 16 ··· 30 31 } 31 32 }); 32 33 33 - const data = await handleResolver.resolve(handle); 34 - return data; 34 + try { 35 + const data = await handleResolver.resolve(handle); 36 + return data; 37 + } catch (error) { 38 + redirect(307, '/?error=handle_not_found&handle=' + handle); 39 + } 35 40 } 36 41 37 42 const didResolver = new CompositeDidDocumentResolver({
+70
src/lib/cards/VCardCard/VCardCard.svelte
··· 1 + <script lang="ts"> 2 + import { Modal } from '@foxui/core'; 3 + import QRCodeDisplay from '$lib/components/qr/QRCodeDisplay.svelte'; 4 + import type { ContentComponentProps } from '../types'; 5 + import { parseVCardName, parseVCardOrg } from '.'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let showQR = $state(false); 10 + 11 + let displayName = $derived( 12 + item.cardData.displayName || parseVCardName(item.cardData.vcard || '') || 'Contact' 13 + ); 14 + let org = $derived(parseVCardOrg(item.cardData.vcard || '')); 15 + </script> 16 + 17 + <button 18 + class="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-3 p-3" 19 + onclick={() => (showQR = true)} 20 + ><div 21 + class="text-base-500 dark:text-base-400 accent:text-base-700 text-[12px] font-medium tracking-wide uppercase" 22 + > 23 + vCard 24 + </div> 25 + <!-- Identification Card icon (Heroicons) --> 26 + <svg 27 + xmlns="http://www.w3.org/2000/svg" 28 + fill="none" 29 + viewBox="0 0 24 24" 30 + stroke-width="1.5" 31 + stroke="currentColor" 32 + class="text-base-700 dark:text-base-300 accent:text-base-900 size-10" 33 + > 34 + <path 35 + stroke-linecap="round" 36 + stroke-linejoin="round" 37 + d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" 38 + /> 39 + </svg> 40 + 41 + <div class="text-center"> 42 + <div class="text-base-900 dark:text-base-100 accent:text-base-900 text-sm font-semibold"> 43 + {displayName} 44 + </div> 45 + {#if org} 46 + <div class="text-base-600 dark:text-base-400 accent:text-base-800 text-xs"> 47 + {org} 48 + </div> 49 + {/if} 50 + </div> 51 + </button> 52 + 53 + <Modal bind:open={showQR} closeButton={true} class="max-w-[90vw]! sm:max-w-sm! md:max-w-md!"> 54 + <div class="flex flex-col items-center justify-center gap-4 p-4"> 55 + <div class="text-base-900 dark:text-base-100 text-center text-2xl font-semibold"> 56 + {displayName} 57 + </div> 58 + 59 + <div class="flex items-center justify-center overflow-hidden rounded-2xl"> 60 + <QRCodeDisplay 61 + url={item.cardData.vcard || ''} 62 + class="size-[min(70vw,320px)] sm:size-72 md:size-80" 63 + /> 64 + </div> 65 + 66 + <p class="text-base-600 dark:text-base-400 text-center text-sm"> 67 + Scan to add contact to your phone 68 + </p> 69 + </div> 70 + </Modal>
+128
src/lib/cards/VCardCard/VCardCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Subheading } from '@foxui/core'; 3 + import type { SettingsComponentProps } from '../types'; 4 + import { parseVCard, generateVCard, parseVCardName, emptyVCardFields, type VCardFields } from '.'; 5 + 6 + let { item = $bindable(), onclose }: SettingsComponentProps = $props(); 7 + 8 + let mode: 'easy' | 'expert' = $state('easy'); 9 + let fields: VCardFields = $state( 10 + parseVCard(item.cardData.vcard || '') || { ...emptyVCardFields } 11 + ); 12 + 13 + function syncFromFields() { 14 + item.cardData.vcard = generateVCard(fields); 15 + item.cardData.displayName = parseVCardName(item.cardData.vcard); 16 + } 17 + 18 + function handleTextarea(e: Event) { 19 + const text = (e.target as HTMLTextAreaElement).value; 20 + item.cardData.vcard = text; 21 + item.cardData.displayName = parseVCardName(text); 22 + fields = parseVCard(text); 23 + } 24 + </script> 25 + 26 + <div class="flex w-72 flex-col gap-3 p-2"> 27 + <Subheading>Edit vCard</Subheading> 28 + 29 + <Alert type="info" title="Privacy"> 30 + <p class="text-xs">All data is public, be aware.</p> 31 + </Alert> 32 + 33 + <div class="flex items-center gap-2 text-xs"> 34 + <button 35 + class={[ 36 + 'rounded px-2 py-1', 37 + mode === 'easy' ? 'bg-accent-500 text-white' : 'bg-base-200 dark:bg-base-700' 38 + ]} 39 + onclick={() => (mode = 'easy')} 40 + > 41 + Easy 42 + </button> 43 + <button 44 + class={[ 45 + 'rounded px-2 py-1', 46 + mode === 'expert' ? 'bg-accent-500 text-white' : 'bg-base-200 dark:bg-base-700' 47 + ]} 48 + onclick={() => (mode = 'expert')} 49 + > 50 + Expert 51 + </button> 52 + <a 53 + href="https://wikipedia.org/wiki/VCard" 54 + target="_blank" 55 + class="text-accent-600 dark:text-accent-400 underline">Learn about the vCard format</a 56 + > 57 + </div> 58 + 59 + {#if mode === 'easy'} 60 + <div class="flex flex-col gap-1 text-xs"> 61 + <div class="grid grid-cols-2 gap-1"> 62 + <input 63 + bind:value={fields.firstName} 64 + oninput={syncFromFields} 65 + placeholder="First name" 66 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 67 + /> 68 + <input 69 + bind:value={fields.lastName} 70 + oninput={syncFromFields} 71 + placeholder="Last name" 72 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 73 + /> 74 + </div> 75 + <input 76 + bind:value={fields.org} 77 + oninput={syncFromFields} 78 + placeholder="Organization" 79 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 80 + /> 81 + <input 82 + bind:value={fields.title} 83 + oninput={syncFromFields} 84 + placeholder="Job title" 85 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 86 + /> 87 + <input 88 + bind:value={fields.email} 89 + oninput={syncFromFields} 90 + placeholder="Email" 91 + type="email" 92 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 93 + /> 94 + <input 95 + bind:value={fields.bday} 96 + oninput={syncFromFields} 97 + placeholder="Birthday" 98 + type="date" 99 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 100 + /> 101 + <input 102 + bind:value={fields.website} 103 + oninput={syncFromFields} 104 + placeholder="Website" 105 + type="url" 106 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 107 + /> 108 + <input 109 + bind:value={fields.address} 110 + oninput={syncFromFields} 111 + placeholder="Address" 112 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 rounded border px-2 py-1" 113 + /> 114 + </div> 115 + {:else} 116 + <textarea 117 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 h-40 w-full resize-none rounded border p-2 font-mono text-xs focus:outline-none" 118 + value={item.cardData.vcard || ''} 119 + oninput={handleTextarea} 120 + placeholder="BEGIN:VCARD 121 + VERSION:4.0 122 + FN:John Doe 123 + END:VCARD" 124 + ></textarea> 125 + {/if} 126 + 127 + <Button onclick={onclose} size="sm">Done</Button> 128 + </div>
+126
src/lib/cards/VCardCard/index.ts
··· 1 + import { user } from '$lib/atproto/auth.svelte'; 2 + import type { CardDefinition } from '../types'; 3 + import VCardCard from './VCardCard.svelte'; 4 + import VCardCardSettings from './VCardCardSettings.svelte'; 5 + 6 + // vCard spec: https://wikipedia.org/wiki/VCard 7 + 8 + export type VCardFields = { 9 + firstName: string; 10 + lastName: string; 11 + org: string; 12 + title: string; 13 + email: string; 14 + bday: string; // YYYY-MM-DD for input, stored as YYYYMMDD 15 + website: string; 16 + address: string; 17 + note: string; 18 + }; 19 + 20 + export const emptyVCardFields: VCardFields = { 21 + firstName: '', 22 + lastName: '', 23 + org: '', 24 + title: '', 25 + email: '', 26 + bday: '', 27 + website: '', 28 + address: '', 29 + note: '' 30 + }; 31 + 32 + // Convert YYYY-MM-DD to YYYYMMDD for vCard 33 + export function formatBdayToVCard(date: string): string { 34 + return date.replace(/-/g, ''); 35 + } 36 + 37 + // Convert YYYYMMDD to YYYY-MM-DD for input 38 + export function formatBdayFromVCard(bday: string): string { 39 + if (bday.length === 8) { 40 + return `${bday.slice(0, 4)}-${bday.slice(4, 6)}-${bday.slice(6, 8)}`; 41 + } 42 + return bday; 43 + } 44 + 45 + // Generate vCard v4 string from fields 46 + export function generateVCard(f: VCardFields): string { 47 + const lines = ['BEGIN:VCARD', 'VERSION:4.0']; 48 + const fn = `${f.firstName} ${f.lastName}`.trim(); 49 + if (fn) lines.push(`FN:${fn}`); 50 + if (f.lastName || f.firstName) lines.push(`N:${f.lastName};${f.firstName};;;`); 51 + if (f.org) lines.push(`ORG:${f.org}`); 52 + if (f.title) lines.push(`TITLE:${f.title}`); 53 + if (f.email) lines.push(`EMAIL:${f.email}`); 54 + if (f.bday) lines.push(`BDAY:${formatBdayToVCard(f.bday)}`); 55 + if (f.website) lines.push(`URL:${f.website}`); 56 + if (f.address) lines.push(`ADR:;;${f.address};;;;`); 57 + if (f.note) lines.push(`NOTE:${f.note}`); 58 + lines.push('END:VCARD'); 59 + return lines.join('\n'); 60 + } 61 + 62 + // Parse vCard string to fields (supports v3 & v4) 63 + export function parseVCard(vcard: string): VCardFields { 64 + const get = (key: string) => { 65 + const m = vcard.match(new RegExp(`^${key}[;:](.*)$`, 'im')); 66 + return m?.[1]?.trim() || ''; 67 + }; 68 + 69 + const n = get('N').split(';'); 70 + let lastName = n[0] || ''; 71 + let firstName = n[1] || ''; 72 + 73 + if (!lastName && !firstName) { 74 + const fn = get('FN').split(' '); 75 + firstName = fn[0] || ''; 76 + lastName = fn.slice(1).join(' ') || ''; 77 + } 78 + 79 + const adr = get('ADR').split(';'); 80 + 81 + return { 82 + firstName, 83 + lastName, 84 + org: get('ORG').split(';')[0], 85 + title: get('TITLE'), 86 + email: get('EMAIL'), 87 + bday: formatBdayFromVCard(get('BDAY')), 88 + website: get('URL'), 89 + address: adr[2] || '', 90 + note: get('NOTE') 91 + }; 92 + } 93 + 94 + // Parse FN (formatted name) or N from vCard 95 + export function parseVCardName(vcard: string): string { 96 + const f = parseVCard(vcard); 97 + return `${f.firstName} ${f.lastName}`.trim(); 98 + } 99 + 100 + // Parse ORG from vCard 101 + export function parseVCardOrg(vcard: string): string { 102 + return parseVCard(vcard).org; 103 + } 104 + 105 + export const VCardCardDefinition = { 106 + type: 'vcard', 107 + contentComponent: VCardCard, 108 + settingsComponent: VCardCardSettings, 109 + 110 + createNew: (card) => { 111 + card.w = 2; 112 + card.h = 2; 113 + card.mobileW = 4; 114 + card.mobileH = 4; 115 + const displayName = user.profile?.displayName || user.profile?.handle || ''; 116 + card.cardData.vcard = generateVCard({ 117 + ...emptyVCardFields, 118 + lastName: displayName 119 + }); 120 + card.cardData.displayName = displayName; 121 + }, 122 + 123 + sidebarButtonText: 'vCard', 124 + allowSetColor: true, 125 + name: 'vCard Card' 126 + } as CardDefinition & { type: 'vcard' };
+3 -1
src/lib/cards/index.ts
··· 26 26 import { StandardSiteDocumentListCardDefinition } from './StandardSiteDocumentListCard'; 27 27 import { StatusphereCardDefinition } from './StatusphereCard'; 28 28 import { EventCardDefinition } from './EventCard'; 29 + import { VCardCardDefinition } from './VCardCard'; 29 30 30 31 export const AllCardDefinitions = [ 31 32 ImageCardDefinition, ··· 54 55 PhotoGalleryCardDefinition, 55 56 StandardSiteDocumentListCardDefinition, 56 57 StatusphereCardDefinition, 57 - EventCardDefinition 58 + EventCardDefinition, 59 + VCardCardDefinition 58 60 ] as const; 59 61 60 62 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+3 -8
src/lib/website/EditableProfile.svelte
··· 5 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 6 import { Button } from '@foxui/core'; 7 7 import { getIsMobile } from './context'; 8 + import type { Editor } from '@tiptap/core'; 9 + import MadeWithBlento from './MadeWithBlento.svelte'; 8 10 9 11 let { data = $bindable() }: { data: WebsiteData } = $props(); 10 12 ··· 169 171 170 172 <div class={['h-10.5 w-1', profilePosition === 'side' && '@5xl/wrapper:hidden']}></div> 171 173 172 - <div class={['hidden text-xs font-light', profilePosition === 'side' && '@5xl/wrapper:block']}> 173 - made with <a 174 - href="https://blento.app" 175 - target="_blank" 176 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 177 - >blento</a 178 - > 179 - </div> 174 + <MadeWithBlento class="hidden @5xl/wrapper:block {profilePosition === 'side' && '@5xl/wrapper:block'}" /> 180 175 </div> 181 176 </div>
+14 -11
src/lib/website/EditableWebsite.svelte
··· 31 31 import { compressImage } from '../helper'; 32 32 import Account from './Account.svelte'; 33 33 import EditBar from './EditBar.svelte'; 34 + import { user } from '$lib/atproto'; 34 35 35 36 let { 36 37 data ··· 754 755 </div> 755 756 </Sidebar> 756 757 757 - <EditBar 758 - {data} 759 - bind:linkValue 760 - bind:isSaving 761 - bind:showingMobileView 762 - {newCard} 763 - {addLink} 764 - {save} 765 - {handleImageInputChange} 766 - {handleVideoInputChange} 767 - /> 758 + {#if user.isLoggedIn} 759 + <EditBar 760 + {data} 761 + bind:linkValue 762 + bind:isSaving 763 + bind:showingMobileView 764 + {newCard} 765 + {addLink} 766 + {save} 767 + {handleImageInputChange} 768 + {handleVideoInputChange} 769 + /> 770 + {/if} 768 771 769 772 <Toaster /> 770 773 </Context>
+85
src/lib/website/EmptyState.svelte
··· 1 + <script lang="ts"> 2 + import { login, user } from '$lib/atproto'; 3 + import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 4 + import Card from '$lib/cards/Card/Card.svelte'; 5 + import type { Item, WebsiteData } from '$lib/types'; 6 + import type { ActorIdentifier } from '@atcute/lexicons'; 7 + import { Button } from '@foxui/core'; 8 + 9 + let { data }: { data: WebsiteData } = $props(); 10 + 11 + let cards = $derived.by((): Item[] => { 12 + const items: Item[] = []; 13 + 14 + // Name + "No blento yet" card 15 + items.push({ 16 + id: 'empty-main', 17 + x: 0, 18 + y: 0, 19 + w: 6, 20 + h: 2, 21 + mobileX: 0, 22 + mobileY: 0, 23 + mobileW: 8, 24 + mobileH: 3, 25 + cardType: 'text', 26 + color: 'red', 27 + cardData: { 28 + text: `## No blento yet!`, 29 + textAlign: 'center', 30 + verticalAlign: 'center' 31 + } 32 + }); 33 + 34 + // Bluesky social icon 35 + items.push({ 36 + id: 'empty-bluesky', 37 + x: 0, 38 + y: 2, 39 + w: 2, 40 + h: 2, 41 + mobileX: 0, 42 + mobileY: 3, 43 + mobileW: 3, 44 + mobileH: 3, 45 + cardType: 'bigsocial', 46 + cardData: { 47 + platform: 'bluesky', 48 + href: `https://bsky.app/profile/${data.handle}`, 49 + color: '0285FF' 50 + } 51 + }); 52 + 53 + return items; 54 + }); 55 + 56 + let maxHeight = $derived(cards.reduce((max, item) => Math.max(max, item.y + item.h), 0)); 57 + 58 + let maxMobileHeight = $derived( 59 + cards.reduce((max, item) => Math.max(max, item.mobileY + item.mobileH), 0) 60 + ); 61 + </script> 62 + 63 + {#each cards as item (item.id)} 64 + <BaseCard {item}> 65 + <Card {item} /> 66 + </BaseCard> 67 + {/each} 68 + 69 + <!-- Spacer for grid height --> 70 + <div class="hidden @[42rem]/grid:block" style="height: {(maxHeight / 8) * 100}cqw;"></div> 71 + <div class="@[42rem]/grid:hidden" style="height: {(maxMobileHeight / 4) * 100}cqw;"></div> 72 + 73 + {#if !user.isLoggedIn} 74 + <div 75 + class="dark:bg-base-950 border-base-200 dark:border-base-900 fixed top-4 right-4 z-20 flex flex-col gap-4 rounded-2xl border bg-white p-4 shadow-lg" 76 + > 77 + <span class="text-sm font-semibold">Login to edit your page</span> 78 + 79 + <Button 80 + onclick={async () => { 81 + await login(data.handle as ActorIdentifier); 82 + }}>Login</Button 83 + > 84 + </div> 85 + {/if}
+12
src/lib/website/MadeWithBlento.svelte
··· 1 + <script lang="ts"> 2 + let { class: className = '' }: { class?: string } = $props(); 3 + </script> 4 + 5 + <div class={['text-xs font-light', className]}> 6 + made with <a 7 + href="https://blento.app" 8 + target="_blank" 9 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 10 + >blento</a 11 + > 12 + </div>
+2 -8
src/lib/website/Profile.svelte
··· 9 9 import { page } from '$app/state'; 10 10 import type { ActorIdentifier } from '@atcute/lexicons'; 11 11 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 12 + import MadeWithBlento from './MadeWithBlento.svelte'; 12 13 13 14 let { 14 15 data, ··· 142 143 </div> 143 144 {/if} 144 145 {/if} 145 - <div class={['hidden text-xs font-light', profilePosition === 'side' && '@5xl/wrapper:block']}> 146 - made with <a 147 - href="https://blento.app" 148 - target="_blank" 149 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 150 - >blento</a 151 - > 152 - </div> 146 + <MadeWithBlento class="hidden {profilePosition === 'side' && '@5xl/wrapper:block'}" /> 153 147 </div> 154 148 </div>
+13 -14
src/lib/website/Website.svelte
··· 13 13 import BaseCard from '../cards/BaseCard/BaseCard.svelte'; 14 14 import type { WebsiteData } from '$lib/types'; 15 15 import Context from './Context.svelte'; 16 + import MadeWithBlento from './MadeWithBlento.svelte'; 16 17 import Head from './Head.svelte'; 17 18 import type { Did, Handle } from '@atcute/lexicons'; 18 19 import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte'; 20 + import EmptyState from './EmptyState.svelte'; 19 21 20 22 let { data }: { data: WebsiteData } = $props(); 21 23 ··· 61 63 > 62 64 <div></div> 63 65 <div bind:this={container} class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 64 - {#each data.cards.toSorted(sortItems) as item (item.id)} 65 - <BaseCard {item}> 66 - <Card {item} /> 67 - </BaseCard> 68 - {/each} 69 - <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 66 + {#if data.cards.length === 0} 67 + <EmptyState {data} /> 68 + {:else} 69 + {#each data.cards.toSorted(sortItems) as item (item.id)} 70 + <BaseCard {item}> 71 + <Card {item} /> 72 + </BaseCard> 73 + {/each} 74 + <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 75 + {/if} 70 76 </div> 71 77 </div> 72 78 73 - <div class="mx-auto block pb-8 text-center text-xs font-light @5xl/wrapper:hidden"> 74 - made with <a 75 - href="https://blento.app" 76 - target="_blank" 77 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 78 - >blento</a 79 - > 80 - </div> 79 + <MadeWithBlento class="mx-auto block pb-8 text-center @5xl/wrapper:hidden" /> 81 80 </div> 82 81 </Context>
+87
src/routes/+error.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/stores'; 3 + import MadeWithBlento from '$lib/website/MadeWithBlento.svelte'; 4 + </script> 5 + 6 + <div 7 + class="bg-base-100 dark:bg-base-950 text-base-900 dark:text-base-50 min-h-screen px-4 py-8 lg:px-8" 8 + > 9 + <div class="@container/grid mx-auto max-w-4xl"> 10 + <!-- Bento Grid --> 11 + <div class="grid grid-cols-4 gap-3 lg:grid-cols-8 lg:gap-4"> 12 + <!-- Error Code - Large prominent card --> 13 + <div 14 + class="col-span-4 row-span-2 flex flex-col items-center justify-center rounded-3xl bg-gradient-to-br from-pink-500 to-rose-500 p-8 text-white lg:col-span-4" 15 + > 16 + <span class="text-8xl font-black lg:text-9xl">{$page.status}</span> 17 + <span class="mt-2 text-xl font-medium opacity-90">Error</span> 18 + </div> 19 + 20 + <!-- Oops card --> 21 + <div 22 + class="col-span-2 flex items-center justify-center rounded-3xl bg-amber-500 p-6 text-white" 23 + > 24 + <span class="text-2xl font-bold lg:text-3xl">Oops!</span> 25 + </div> 26 + 27 + <!-- Decorative emoji card --> 28 + <div 29 + class="col-span-2 flex items-center justify-center rounded-3xl bg-violet-500 p-6 text-4xl lg:text-5xl" 30 + > 31 + <span class="animate-bounce">:(</span> 32 + </div> 33 + 34 + <!-- Message card --> 35 + <div 36 + class="bg-base-200/50 dark:bg-base-800/50 text-base-700 dark:text-base-300 col-span-4 flex items-center justify-center rounded-3xl p-6 text-center" 37 + > 38 + <p class="text-lg font-medium"> 39 + {$page.error?.message || 40 + "Something went wrong. We couldn't find what you're looking for."} 41 + </p> 42 + </div> 43 + 44 + <!-- Decorative pattern card --> 45 + <div 46 + class="col-span-2 flex items-center justify-center overflow-hidden rounded-3xl bg-cyan-500 p-4" 47 + > 48 + <div class="grid grid-cols-3 gap-2"> 49 + {#each Array(9) as _, i (i)} 50 + <div class="h-3 w-3 rounded-full bg-white/40 lg:h-4 lg:w-4"></div> 51 + {/each} 52 + </div> 53 + </div> 54 + 55 + <!-- Home link card --> 56 + <a 57 + href="/" 58 + class="col-span-2 flex items-center justify-center rounded-3xl bg-emerald-500 p-6 text-white transition-transform hover:scale-[1.02] active:scale-[0.98]" 59 + > 60 + <span class="text-lg font-bold lg:text-xl">Go Home</span> 61 + </a> 62 + 63 + <!-- Decorative stripes card --> 64 + <div class="col-span-2 overflow-hidden rounded-3xl bg-blue-500 p-4"> 65 + <div class="flex h-full w-full flex-col justify-center gap-2"> 66 + <div class="h-2 w-full rounded-full bg-white/30"></div> 67 + <div class="h-2 w-3/4 rounded-full bg-white/40"></div> 68 + <div class="h-2 w-1/2 rounded-full bg-white/50"></div> 69 + </div> 70 + </div> 71 + 72 + <!-- Decorative circles card --> 73 + <div 74 + class="col-span-2 flex items-center justify-center rounded-3xl bg-orange-500 p-4 lg:col-span-2" 75 + > 76 + <div class="relative h-16 w-16 lg:h-20 lg:w-20"> 77 + <div class="absolute inset-0 rounded-full border-4 border-white/40"></div> 78 + <div class="absolute inset-2 rounded-full border-4 border-white/50"></div> 79 + <div class="absolute inset-4 rounded-full border-4 border-white/60"></div> 80 + </div> 81 + </div> 82 + </div> 83 + </div> 84 + 85 + <!-- Footer --> 86 + <MadeWithBlento class="text-base-500 mt-12 text-center" /> 87 + </div>
+15 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 - import { ThemeToggle } from '@foxui/core'; 4 + import { ThemeToggle, Toaster, toast } from '@foxui/core'; 5 5 import { onMount } from 'svelte'; 6 6 import { initClient } from '$lib/atproto'; 7 7 import YoutubeVideoPlayer, { videoPlayer } from '$lib/components/YoutubeVideoPlayer.svelte'; 8 + import { page } from '$app/state'; 9 + import { goto } from '$app/navigation'; 8 10 9 11 let { children } = $props(); 12 + 13 + const errorMessages: Record<string, (params: URLSearchParams) => string> = { 14 + handle_not_found: (p) => `Handle ${p.get('handle') ?? ''} not found!` 15 + }; 10 16 11 17 onMount(() => { 12 18 initClient(); 19 + 20 + const error = page.url.searchParams.get('error'); 21 + if (error) { 22 + const msg = errorMessages[error]?.(page.url.searchParams) ?? error; 23 + toast.error(msg); 24 + goto(page.url.pathname, { replaceState: true }); 25 + } 13 26 }); 14 27 </script> 15 28 16 29 {@render children()} 17 30 18 31 <ThemeToggle class="fixed top-2 left-2 z-10" /> 32 + <Toaster /> 19 33 20 34 {#if videoPlayer.id} 21 35 <YoutubeVideoPlayer />