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

Configure Feed

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

settings page, worker analytics, legal policies

Florian b13ef794 0edb65db

+1213 -22
+3 -1
src/app.d.ts
··· 2 2 KVNamespace, 3 3 D1Database, 4 4 ExecutionContext, 5 - CacheStorage 5 + CacheStorage, 6 + AnalyticsEngineDataset 6 7 } from '@cloudflare/workers-types'; 7 8 import type { OAuthSession } from '@atcute/oauth-node-client'; 8 9 import type { Client } from '@atcute/client'; ··· 27 28 OAUTH_SESSIONS: KVNamespace; 28 29 OAUTH_STATES: KVNamespace; 29 30 DB: D1Database; 31 + ANALYTICS: AnalyticsEngineDataset; 30 32 CLIENT_ASSERTION_KEY: string; 31 33 COOKIE_SECRET: string; 32 34 CRON_SECRET: string;
+36
src/lib/analytics.ts
··· 1 + type PageviewEvent = { 2 + did: string; 3 + handle: string; 4 + page: string; 5 + request: Request; 6 + }; 7 + 8 + const BOT_PATTERN = /bot|crawl|spider|slurp|facebookexternalhit|embedly|preview/i; 9 + 10 + function refererHost(referer: string | null): string { 11 + if (!referer) return ''; 12 + try { 13 + return new URL(referer).hostname; 14 + } catch { 15 + return ''; 16 + } 17 + } 18 + 19 + export function logPageview( 20 + platform: App.Platform | undefined, 21 + { did, handle, page, request }: PageviewEvent 22 + ): void { 23 + const ae = platform?.env?.ANALYTICS; 24 + if (!ae) return; 25 + 26 + const ua = request.headers.get('user-agent') ?? ''; 27 + if (BOT_PATTERN.test(ua)) return; 28 + 29 + const country = request.headers.get('cf-ipcountry') ?? ''; 30 + const referer = refererHost(request.headers.get('referer')); 31 + 32 + ae.writeDataPoint({ 33 + indexes: [did], 34 + blobs: [did, handle, page, country, referer] 35 + }); 36 + }
+2 -14
src/lib/website/Account.svelte
··· 4 4 import { getHandleOrDid } from '$lib/atproto/methods'; 5 5 import type { WebsiteData } from '$lib/types'; 6 6 import { Avatar, Button, Popover } from '@foxui/core'; 7 - import CustomDomainModal, { customDomainModalState } from '$lib/website/CustomDomainModal.svelte'; 8 - import SettingsModal, { settingsModalState } from '$lib/website/SettingsModal.svelte'; 7 + import { settingsOverlayState } from '$lib/website/settings/SettingsOverlay.svelte'; 9 8 10 9 let { 11 10 data = $bindable() ··· 39 38 variant="ghost" 40 39 onclick={() => { 41 40 settingsPopoverOpen = false; 42 - settingsModalState.show(); 41 + settingsOverlayState.show(); 43 42 }}>Settings</Button 44 43 > 45 44 46 - <Button 47 - variant="ghost" 48 - onclick={() => { 49 - settingsPopoverOpen = false; 50 - customDomainModalState.show(); 51 - }}>Custom Domain</Button 52 - > 53 - 54 45 <Button variant="ghost" onclick={logout}>Logout</Button> 55 46 </div> 56 47 </Popover> 57 48 </div> 58 - 59 - <CustomDomainModal publicationUrl={data.publication?.url} /> 60 - <SettingsModal bind:data /> 61 49 {/if}
+2
src/lib/website/EditableWebsite.svelte
··· 23 23 import Context from './Context.svelte'; 24 24 import Head from './Head.svelte'; 25 25 import Account from './Account.svelte'; 26 + import SettingsOverlay from './settings/SettingsOverlay.svelte'; 26 27 import EditBar from './EditBar.svelte'; 27 28 import SaveModal from './SaveModal.svelte'; 28 29 import FloatingEditButton from './FloatingEditButton.svelte'; ··· 387 388 /> 388 389 389 390 <Account bind:data /> 391 + <SettingsOverlay bind:data publicationUrl={data.publication?.url} /> 390 392 391 393 <Context {data} isEditing={true}> 392 394 <ImageViewerProvider />
+21 -6
src/lib/website/MadeWithBlento.svelte
··· 3 3 </script> 4 4 5 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 - > 6 + <div> 7 + made with <a 8 + href="https://blento.app" 9 + target="_blank" 10 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 11 + >blento</a 12 + > 13 + </div> 14 + <div class="mt-1"> 15 + <a 16 + href="/imprint" 17 + class="hover:text-accent-600 dark:hover:text-accent-400 transition-colors duration-200" 18 + >Imprint</a 19 + > 20 + <span class="mx-1 opacity-50">&middot;</span> 21 + <a 22 + href="/privacy" 23 + class="hover:text-accent-600 dark:hover:text-accent-400 transition-colors duration-200" 24 + >Privacy</a 25 + > 26 + </div> 12 27 </div>
+91
src/lib/website/settings/SettingsOverlay.svelte
··· 1 + <script lang="ts" module> 2 + export const settingsOverlayState = $state({ 3 + visible: false, 4 + activeSection: 'layout' as string, 5 + show: () => (settingsOverlayState.visible = true), 6 + hide: () => (settingsOverlayState.visible = false) 7 + }); 8 + </script> 9 + 10 + <script lang="ts"> 11 + import type { WebsiteData } from '$lib/types'; 12 + import LayoutSection from './sections/LayoutSection.svelte'; 13 + import CustomDomainSection from './sections/CustomDomainSection.svelte'; 14 + import AccountSection from './sections/AccountSection.svelte'; 15 + 16 + let { data = $bindable(), publicationUrl }: { data: WebsiteData; publicationUrl?: string } = 17 + $props(); 18 + 19 + $effect(() => { 20 + if (settingsOverlayState.visible) { 21 + document.body.style.overflow = 'hidden'; 22 + return () => { 23 + document.body.style.overflow = ''; 24 + }; 25 + } 26 + }); 27 + 28 + const tabs = [ 29 + { id: 'layout', label: 'Layout' }, 30 + { id: 'domain', label: 'Custom Domain' }, 31 + { id: 'account', label: 'Account' } 32 + ] as const; 33 + </script> 34 + 35 + {#if settingsOverlayState.visible} 36 + <div class="bg-base-50 dark:bg-base-950 fixed inset-0 z-[100] flex flex-col overflow-hidden"> 37 + <!-- Header with tabs and close button --> 38 + <div 39 + class="border-base-200 dark:border-base-800 flex items-center justify-between border-b px-6 py-4" 40 + > 41 + <div class="flex items-center gap-6"> 42 + <h2 class="text-base-900 dark:text-base-100 text-lg font-semibold">Settings</h2> 43 + <nav class="flex gap-1 overflow-x-auto"> 44 + {#each tabs as tab (tab.id)} 45 + <button 46 + type="button" 47 + class="cursor-pointer rounded-lg px-3 py-1.5 text-sm font-medium whitespace-nowrap {settingsOverlayState.activeSection === 48 + tab.id 49 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-100' 50 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-300'}" 51 + onclick={() => (settingsOverlayState.activeSection = tab.id)} 52 + > 53 + {tab.label} 54 + </button> 55 + {/each} 56 + </nav> 57 + </div> 58 + <!-- Close button --> 59 + <button 60 + type="button" 61 + class="text-base-500 hover:text-base-700 dark:text-base-400 dark:hover:text-base-200 cursor-pointer rounded-lg p-1.5" 62 + onclick={() => settingsOverlayState.hide()} 63 + > 64 + <svg 65 + xmlns="http://www.w3.org/2000/svg" 66 + fill="none" 67 + viewBox="0 0 24 24" 68 + stroke-width="1.5" 69 + stroke="currentColor" 70 + class="size-5" 71 + > 72 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 73 + </svg> 74 + <span class="sr-only">Close settings</span> 75 + </button> 76 + </div> 77 + 78 + <!-- Content area --> 79 + <div class="flex-1 overflow-y-auto px-6 py-8"> 80 + <div class="mx-auto max-w-xl"> 81 + {#if settingsOverlayState.activeSection === 'layout'} 82 + <LayoutSection bind:data /> 83 + {:else if settingsOverlayState.activeSection === 'domain'} 84 + <CustomDomainSection {publicationUrl} /> 85 + {:else if settingsOverlayState.activeSection === 'account'} 86 + <AccountSection /> 87 + {/if} 88 + </div> 89 + </div> 90 + </div> 91 + {/if}
+99
src/lib/website/settings/sections/AccountSection.svelte
··· 1 + <script lang="ts"> 2 + import { deleteRecord, listRecords, parseUri } from '$lib/atproto/methods'; 3 + import { user, logout } from '$lib/atproto'; 4 + import { Button } from '@foxui/core'; 5 + import type { Did } from '@atcute/lexicons'; 6 + import { settingsOverlayState } from '../SettingsOverlay.svelte'; 7 + 8 + const blentoCollections = [ 9 + 'app.blento.card', 10 + 'app.blento.page', 11 + 'app.blento.section', 12 + 'app.blento.settings', 13 + 'site.standard.publication' 14 + ] as const; 15 + 16 + let deleteConfirm = $state(false); 17 + let isDeleting = $state(false); 18 + let deleteError = $state(''); 19 + 20 + async function deleteAllData() { 21 + if (!user.did) return; 22 + isDeleting = true; 23 + deleteError = ''; 24 + 25 + try { 26 + for (const collection of blentoCollections) { 27 + const records = await listRecords({ 28 + did: user.did as Did, 29 + collection, 30 + limit: 0 31 + }); 32 + for (const record of records) { 33 + const parsed = parseUri(record.uri); 34 + if (parsed) { 35 + await deleteRecord({ collection, rkey: parsed.rkey! }); 36 + } 37 + } 38 + } 39 + 40 + settingsOverlayState.hide(); 41 + logout(); 42 + } catch (err: unknown) { 43 + deleteError = err instanceof Error ? err.message : String(err); 44 + } finally { 45 + isDeleting = false; 46 + } 47 + } 48 + </script> 49 + 50 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Account</h3> 51 + 52 + <div class="mt-6"> 53 + <Button 54 + variant="ghost" 55 + onclick={() => { 56 + settingsOverlayState.hide(); 57 + logout(); 58 + }} 59 + > 60 + Logout 61 + </Button> 62 + </div> 63 + 64 + <div 65 + class="mt-6 rounded-xl border border-red-200 bg-red-100 p-4 dark:border-red-800 dark:bg-red-950/40" 66 + > 67 + <h4 class="text-sm font-semibold text-red-600 dark:text-red-400">Danger Zone</h4> 68 + 69 + <p class="text-base-600 dark:text-base-400 mt-2 text-sm"> 70 + Delete all your Blento data (cards, pages, sections, and settings). This does 71 + <strong>not</strong> delete your account — only your Blento site data stored in your PDS. 72 + </p> 73 + 74 + {#if deleteError} 75 + <p class="mt-2 text-sm text-red-500 dark:text-red-400">{deleteError}</p> 76 + {/if} 77 + 78 + {#if !deleteConfirm} 79 + <Button 80 + variant="ghost" 81 + class="mt-3 text-red-600 dark:text-red-400" 82 + onclick={() => (deleteConfirm = true)} 83 + > 84 + Delete all my data 85 + </Button> 86 + {:else} 87 + <p class="text-base-800 dark:text-base-200 mt-3 text-sm font-medium"> 88 + Are you sure? This cannot be undone. 89 + </p> 90 + <div class="mt-2 flex gap-2"> 91 + <Button variant="ghost" onclick={() => (deleteConfirm = false)} disabled={isDeleting}> 92 + Cancel 93 + </Button> 94 + <Button onclick={deleteAllData} disabled={isDeleting} variant="red"> 95 + {isDeleting ? 'Deleting...' : 'Yes, delete everything'} 96 + </Button> 97 + </div> 98 + {/if} 99 + </div>
+250
src/lib/website/settings/sections/CustomDomainSection.svelte
··· 1 + <script lang="ts"> 2 + import { putRecord, getRecord } from '$lib/atproto/methods'; 3 + import { user } from '$lib/atproto'; 4 + import { Button, Input } from '@foxui/core'; 5 + import { launchConfetti } from '@foxui/visual'; 6 + import { settingsOverlayState } from '../SettingsOverlay.svelte'; 7 + 8 + let { publicationUrl }: { publicationUrl?: string } = $props(); 9 + 10 + let currentDomain = $derived( 11 + publicationUrl?.startsWith('https://') && !publicationUrl.includes('blento.app') 12 + ? publicationUrl.replace('https://', '') 13 + : '' 14 + ); 15 + 16 + let step: 'current' | 'input' | 'instructions' | 'verifying' | 'removing' | 'success' | 'error' = 17 + $state('input'); 18 + let rawDomain = $state(''); 19 + let domain = $derived(rawDomain.replace(/^https?:\/\//, '').replace(/\/+$/, '')); 20 + let errorMessage = $state(''); 21 + let errorHint = $state(''); 22 + 23 + $effect(() => { 24 + if (settingsOverlayState.visible && settingsOverlayState.activeSection === 'domain') { 25 + step = currentDomain ? 'current' : 'input'; 26 + } 27 + }); 28 + 29 + async function removeDomain() { 30 + step = 'removing'; 31 + try { 32 + const existing = await getRecord({ 33 + collection: 'site.standard.publication', 34 + rkey: 'blento.self' 35 + }); 36 + 37 + if (existing?.value) { 38 + const { url: _url, ...rest } = existing.value as Record<string, unknown>; 39 + await putRecord({ 40 + collection: 'site.standard.publication', 41 + rkey: 'blento.self', 42 + record: rest 43 + }); 44 + } 45 + 46 + step = 'input'; 47 + } catch (err: unknown) { 48 + errorMessage = err instanceof Error ? err.message : String(err); 49 + step = 'error'; 50 + } 51 + } 52 + 53 + function goToInstructions() { 54 + if (!domain.trim()) return; 55 + step = 'instructions'; 56 + } 57 + 58 + async function verify() { 59 + step = 'verifying'; 60 + try { 61 + const dnsRes = await fetch('/api/verify-domain', { 62 + method: 'POST', 63 + headers: { 'Content-Type': 'application/json' }, 64 + body: JSON.stringify({ domain }) 65 + }); 66 + 67 + const dnsData = await dnsRes.json(); 68 + 69 + if (!dnsRes.ok || dnsData.error) { 70 + errorMessage = dnsData.error; 71 + errorHint = dnsData.hint || ''; 72 + step = 'error'; 73 + return; 74 + } 75 + 76 + const existing = await getRecord({ 77 + collection: 'site.standard.publication', 78 + rkey: 'blento.self' 79 + }); 80 + 81 + await putRecord({ 82 + collection: 'site.standard.publication', 83 + rkey: 'blento.self', 84 + record: { 85 + ...(existing?.value || {}), 86 + url: 'https://' + domain 87 + } 88 + }); 89 + 90 + const activateRes = await fetch('/api/activate-domain', { 91 + method: 'POST', 92 + headers: { 'Content-Type': 'application/json' }, 93 + body: JSON.stringify({ did: user.did, domain }) 94 + }); 95 + 96 + const activateData = await activateRes.json(); 97 + 98 + if (!activateRes.ok || activateData.error) { 99 + errorMessage = activateData.error; 100 + errorHint = ''; 101 + step = 'error'; 102 + return; 103 + } 104 + 105 + launchConfetti(); 106 + step = 'success'; 107 + } catch (err: unknown) { 108 + errorMessage = err instanceof Error ? err.message : String(err); 109 + step = 'error'; 110 + } 111 + } 112 + 113 + async function copyToClipboard(text: string) { 114 + await navigator.clipboard.writeText(text); 115 + } 116 + </script> 117 + 118 + {#if step === 'current'} 119 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Custom Domain</h3> 120 + 121 + <div 122 + class="bg-base-200 dark:bg-base-700 mt-3 flex items-center justify-between rounded-2xl px-3 py-2 font-mono text-sm" 123 + > 124 + <span>{currentDomain}</span> 125 + </div> 126 + 127 + <div class="mt-4 flex gap-2"> 128 + <Button variant="ghost" onclick={removeDomain}>Remove</Button> 129 + <Button variant="ghost" onclick={() => (step = 'input')}>Change</Button> 130 + <Button onclick={() => settingsOverlayState.hide()}>Close</Button> 131 + </div> 132 + {:else if step === 'input'} 133 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Custom Domain</h3> 134 + 135 + <div class="mt-3"> 136 + <Input type="text" bind:value={rawDomain} placeholder="mydomain.com" /> 137 + </div> 138 + 139 + <div class="mt-4 flex gap-2"> 140 + <Button variant="ghost" onclick={() => settingsOverlayState.hide()}>Cancel</Button> 141 + <Button onclick={goToInstructions} disabled={!domain.trim()}>Next</Button> 142 + </div> 143 + {:else if step === 'instructions'} 144 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Set up your domain</h3> 145 + 146 + <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 147 + Add a CNAME record for your domain pointing to: 148 + </p> 149 + 150 + <div 151 + class="bg-base-200 dark:bg-base-700 mt-2 flex items-center justify-between rounded-2xl px-3 py-2 font-mono text-sm" 152 + > 153 + <span>blento-proxy.fly.dev</span> 154 + <button 155 + class="text-base-600 hover:text-base-900 dark:text-base-400 dark:hover:text-base-100 ml-2 cursor-pointer" 156 + onclick={() => copyToClipboard('blento-proxy.fly.dev')} 157 + > 158 + <svg 159 + xmlns="http://www.w3.org/2000/svg" 160 + fill="none" 161 + viewBox="0 0 24 24" 162 + stroke-width="1.5" 163 + stroke="currentColor" 164 + class="size-4" 165 + > 166 + <path 167 + stroke-linecap="round" 168 + stroke-linejoin="round" 169 + d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" 170 + /> 171 + </svg> 172 + <span class="sr-only">Copy to clipboard</span> 173 + </button> 174 + </div> 175 + 176 + <div class="mt-4 flex gap-2"> 177 + <Button variant="ghost" onclick={() => (step = 'input')}>Back</Button> 178 + <Button onclick={verify}>Verify</Button> 179 + </div> 180 + {:else if step === 'verifying'} 181 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Verifying...</h3> 182 + 183 + <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 184 + Checking DNS records and verifying your domain. 185 + </p> 186 + 187 + <div class="mt-4 flex items-center gap-2"> 188 + <svg 189 + class="text-base-500 size-5 animate-spin" 190 + xmlns="http://www.w3.org/2000/svg" 191 + fill="none" 192 + viewBox="0 0 24 24" 193 + > 194 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 195 + ></circle> 196 + <path 197 + class="opacity-75" 198 + fill="currentColor" 199 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 200 + ></path> 201 + </svg> 202 + <span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span> 203 + </div> 204 + {:else if step === 'removing'} 205 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Removing...</h3> 206 + 207 + <div class="mt-4 flex items-center gap-2"> 208 + <svg 209 + class="text-base-500 size-5 animate-spin" 210 + xmlns="http://www.w3.org/2000/svg" 211 + fill="none" 212 + viewBox="0 0 24 24" 213 + > 214 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 215 + ></circle> 216 + <path 217 + class="opacity-75" 218 + fill="currentColor" 219 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 220 + ></path> 221 + </svg> 222 + <span class="text-base-600 dark:text-base-400 text-sm">Removing domain...</span> 223 + </div> 224 + {:else if step === 'success'} 225 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Domain verified!</h3> 226 + 227 + <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 228 + Your custom domain {domain} has been set up successfully. 229 + </p> 230 + 231 + <div class="mt-4"> 232 + <Button onclick={() => settingsOverlayState.hide()}>Close</Button> 233 + </div> 234 + {:else if step === 'error'} 235 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Verification failed</h3> 236 + 237 + <p class="mt-2 text-sm text-red-500 dark:text-red-400"> 238 + {errorMessage} 239 + </p> 240 + {#if errorHint} 241 + <p class="mt-1 text-sm font-bold text-red-500 dark:text-red-400"> 242 + {errorHint} 243 + </p> 244 + {/if} 245 + 246 + <div class="mt-4 flex gap-2"> 247 + <Button variant="ghost" onclick={() => settingsOverlayState.hide()}>Close</Button> 248 + <Button onclick={verify}>Retry</Button> 249 + </div> 250 + {/if}
+82
src/lib/website/settings/sections/LayoutSection.svelte
··· 1 + <script lang="ts"> 2 + import type { WebsiteData } from '$lib/types'; 3 + 4 + let { data = $bindable() }: { data: WebsiteData } = $props(); 5 + 6 + type LayoutMode = NonNullable<WebsiteData['publication']['preferences']>['layoutMode']; 7 + 8 + type LayoutOption = { 9 + key: string; 10 + value: LayoutMode; 11 + label: string; 12 + description: string; 13 + }; 14 + 15 + const options: LayoutOption[] = [ 16 + { 17 + key: 'automatic', 18 + value: undefined, 19 + label: 'Automatic', 20 + description: 'Automatically syncs layouts until both are edited independently' 21 + }, 22 + { 23 + key: 'desktop-leads', 24 + value: 'desktop-leads', 25 + label: 'Desktop drives mobile', 26 + description: 'Desktop edits update mobile, mobile edits are independent' 27 + }, 28 + { 29 + key: 'mobile-leads', 30 + value: 'mobile-leads', 31 + label: 'Mobile drives desktop', 32 + description: 'Mobile edits update desktop, desktop edits are independent' 33 + }, 34 + { 35 + key: 'independent', 36 + value: 'independent', 37 + label: 'Independent', 38 + description: 'Desktop and mobile layouts are fully independent' 39 + } 40 + ]; 41 + 42 + let selected = $derived(data.publication.preferences?.layoutMode ?? 'automatic'); 43 + 44 + function selectOption(option: LayoutOption) { 45 + data.publication.preferences ??= {}; 46 + data.publication.preferences.layoutMode = option.value; 47 + data = { ...data }; 48 + } 49 + </script> 50 + 51 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Layout Sync</h3> 52 + <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 53 + Control how desktop and mobile layouts stay in sync. 54 + </p> 55 + 56 + <div class="mt-4 flex flex-col gap-2"> 57 + {#each options as option (option.key)} 58 + {@const isSelected = option.key === (selected === undefined ? 'automatic' : selected)} 59 + <button 60 + type="button" 61 + class="border-base-200 dark:border-base-700 hover:border-base-300 dark:hover:border-base-600 cursor-pointer rounded-xl border-2 px-4 py-3 text-left transition-colors {isSelected 62 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950/20' 63 + : 'bg-base-50 dark:bg-base-800/50'}" 64 + onclick={() => selectOption(option)} 65 + > 66 + <span 67 + class="text-sm font-semibold {isSelected 68 + ? 'text-accent-700 dark:text-accent-300' 69 + : 'text-base-900 dark:text-base-100'}" 70 + > 71 + {option.label} 72 + </span> 73 + <p 74 + class="mt-0.5 text-xs {isSelected 75 + ? 'text-accent-600 dark:text-accent-400' 76 + : 'text-base-500 dark:text-base-400'}" 77 + > 78 + {option.description} 79 + </p> 80 + </button> 81 + {/each} 82 + </div>
+1 -1
src/routes/(auth)/oauth/callback/+server.ts
··· 33 33 34 34 const profile = await getDetailedProfile({ did }).catch(() => undefined); 35 35 const actor = profile?.handle && profile.handle !== 'handle.invalid' ? profile.handle : did; 36 - redirect(303, `/${actor}`); 36 + redirect(303, `/${actor}/edit`); 37 37 };
+26
src/routes/(legal)/+layout.svelte
··· 1 + <script lang="ts"> 2 + import MadeWithBlento from '$lib/website/MadeWithBlento.svelte'; 3 + 4 + let { children } = $props(); 5 + </script> 6 + 7 + <div 8 + class="bg-base-100 dark:bg-base-950 text-base-900 dark:text-base-50 min-h-screen px-4 py-12 lg:px-8" 9 + > 10 + <div class="mx-auto max-w-3xl"> 11 + <a 12 + href="/" 13 + class="text-base-500 hover:text-base-900 dark:hover:text-base-100 mb-8 inline-block text-sm transition-colors" 14 + > 15 + &larr; Back to blento 16 + </a> 17 + 18 + <article 19 + class="prose prose-base-900 dark:prose-invert prose-headings:font-bold prose-a:text-accent-600 dark:prose-a:text-accent-400 max-w-none" 20 + > 21 + {@render children()} 22 + </article> 23 + 24 + <MadeWithBlento class="text-base-500 mt-16 text-center" /> 25 + </div> 26 + </div>
+97
src/routes/(legal)/imprint/+page.svelte
··· 1 + <svelte:head> 2 + <title>Imprint &middot; blento</title> 3 + <meta name="description" content="Imprint / Impressum for blento." /> 4 + </svelte:head> 5 + 6 + <h1>Imprint</h1> 7 + <p><em>Last updated: April 18, 2026</em></p> 8 + 9 + <p> 10 + Information in accordance with &sect; 5 TMG (German Telemedia Act) and &sect; 18 MStV (German 11 + Interstate Media Treaty). 12 + </p> 13 + 14 + <h2>Operator</h2> 15 + <p>Florian Killius</p> 16 + 17 + <h2>Contact</h2> 18 + <ul> 19 + <li>Email: <a href="mailto:hello@blento.app">hello@blento.app</a></li> 20 + <li> 21 + Bluesky: 22 + <a href="https://bsky.app/profile/blento.app" target="_blank" rel="noopener">@blento.app</a> 23 + </li> 24 + </ul> 25 + 26 + <h2>Responsible for Content</h2> 27 + <p> 28 + Responsible for content according to &sect; 18 Abs. 2 MStV:<br /> 29 + Florian Killius 30 + </p> 31 + 32 + <h2>EU Dispute Resolution</h2> 33 + <p> 34 + The European Commission provides a platform for online dispute resolution (OS): 35 + <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener" 36 + >https://ec.europa.eu/consumers/odr</a 37 + >. We are not obliged and not willing to participate in dispute resolution proceedings before a 38 + consumer arbitration board (&sect; 36 VSBG). 39 + </p> 40 + 41 + <h2>Reporting Illegal Content (DSA)</h2> 42 + <p> 43 + In accordance with Article 16 of the EU Digital Services Act (Regulation (EU) 2022/2065), anyone 44 + may report content that they consider illegal by sending a notice to 45 + <a href="mailto:hello@blento.app">hello@blento.app</a>. Please include: 46 + </p> 47 + <ul> 48 + <li> 49 + a sufficiently substantiated explanation of why you believe the content is illegal under EU or 50 + Member State law; 51 + </li> 52 + <li>the exact URL(s) of the content;</li> 53 + <li> 54 + your name and email address (unless the report concerns offences under Arts. 3–7 of Directive 55 + 2011/93/EU, in which case anonymity is allowed); 56 + </li> 57 + <li>a statement, in good faith, that the information in the notice is accurate and complete.</li> 58 + </ul> 59 + <p> 60 + Submitting a complete notice gives us "actual knowledge" under Art. 6 DSA, and we will act 61 + expeditiously to assess and, where appropriate, remove or disable access to the content. You will 62 + receive an acknowledgement and, where feasible, a statement of reasons. 63 + </p> 64 + 65 + <h2>Disclaimer</h2> 66 + 67 + <h3>Liability for Content</h3> 68 + <p> 69 + The content of this site has been created with the utmost care. However, no guarantee is given for 70 + the accuracy, completeness, or timeliness of the content. As a service provider, we are 71 + responsible for our own content on these pages under general law. We are not, however, obliged to 72 + monitor transmitted or stored third-party information, or to investigate circumstances that 73 + indicate illegal activity. 74 + </p> 75 + 76 + <h3>Liability for Links</h3> 77 + <p> 78 + Our site contains links to external websites of third parties, over whose content we have no 79 + influence. Therefore, we cannot assume any liability for this external content. The respective 80 + provider or operator of the linked pages is always responsible for the content of the linked 81 + pages. 82 + </p> 83 + 84 + <h3>User-Generated Content</h3> 85 + <p> 86 + blento renders content authored by its users and stored in their own Personal Data Servers on the 87 + AT Protocol network. Responsibility for such content lies with the respective user. If you believe 88 + user-generated content accessed via blento infringes your rights or violates applicable law, 89 + please contact us at <a href="mailto:hello@blento.app">hello@blento.app</a>. 90 + </p> 91 + 92 + <h3>Copyright</h3> 93 + <p> 94 + Content created by the operator of this site is subject to copyright law. Reproduction, 95 + processing, distribution, or any form of commercialisation of such material beyond the scope of 96 + copyright law requires the prior written consent of its respective author or creator. 97 + </p>
+250
src/routes/(legal)/privacy/+page.svelte
··· 1 + <svelte:head> 2 + <title>Privacy Policy &middot; blento</title> 3 + <meta name="description" content="Privacy Policy for blento." /> 4 + </svelte:head> 5 + 6 + <h1>Privacy Policy</h1> 7 + <p><em>Last updated: April 18, 2026</em></p> 8 + 9 + <h2>1. Overview</h2> 10 + <p> 11 + blento is built on the AT Protocol. Your site content lives in your own Personal Data Server (PDS) 12 + under your atmosphere account. This policy explains what limited data blento itself handles and 13 + how we comply with the EU General Data Protection Regulation (GDPR), the German Federal Data 14 + Protection Act (BDSG), and the German Telecommunications-Digital-Services Data Protection Act 15 + (TDDDG, formerly TTDSG). 16 + </p> 17 + 18 + <h2>2. Definitions</h2> 19 + <ul> 20 + <li> 21 + <strong>AT Protocol (atproto):</strong> the open decentralized protocol that the Service is 22 + built on. See 23 + <a href="https://atproto.com" target="_blank" rel="noopener">atproto.com</a>. 24 + </li> 25 + <li> 26 + <strong>Atmosphere:</strong> the open network of applications and services built on the AT Protocol. 27 + Bluesky is one application in the atmosphere; blento is another. 28 + </li> 29 + <li> 30 + <strong>Atmosphere account:</strong> your identity on the atmosphere, represented by a DID (decentralized 31 + identifier) and a handle. You can use the same account across any atmosphere service, including blento. 32 + </li> 33 + <li> 34 + <strong>Personal Data Server (PDS):</strong> the server that hosts the data for your atmosphere account. 35 + It stores your records (including the cards you create on blento) and is the canonical home of your 36 + content. You can self-host your PDS or use one operated by a provider. 37 + </li> 38 + </ul> 39 + 40 + <h2>3. Controller</h2> 41 + <p>The controller responsible for processing under Art. 4(7) GDPR is:</p> 42 + <p> 43 + Florian Killius<br /> 44 + Email: <a href="mailto:hello@blento.app">hello@blento.app</a> 45 + </p> 46 + <p> 47 + We have not appointed a data protection officer; we are below the thresholds of &sect; 38 BDSG. 48 + For any data-protection inquiries, contact the address above. 49 + </p> 50 + 51 + <h2>4. Categories of Data and Legal Bases</h2> 52 + 53 + <h3>4.1 Authentication (atproto OAuth)</h3> 54 + <p> 55 + When you sign in, we initiate an OAuth flow with your PDS. We receive and store, in a session 56 + cookie and in server-side session storage, an access token, a refresh token, your DID, and your 57 + handle. We do not receive or store your password. 58 + </p> 59 + <ul> 60 + <li><strong>Purpose:</strong> authenticating you and keeping you signed in.</li> 61 + <li> 62 + <strong>Legal basis:</strong> Art. 6(1)(b) GDPR (performance of the service you requested). 63 + </li> 64 + <li> 65 + <strong>Retention:</strong> until you sign out or the refresh token expires / is revoked. 66 + </li> 67 + <li> 68 + <strong>Storage of auth tokens on your device:</strong> legally necessary for the service you requested 69 + under &sect; 25(2) Nr. 2 TDDDG; no consent required. 70 + </li> 71 + </ul> 72 + 73 + <h3>4.2 Your DID and handle</h3> 74 + <p>Used to load and save your bento grid records from and to your PDS.</p> 75 + <ul> 76 + <li><strong>Legal basis:</strong> Art. 6(1)(b) GDPR.</li> 77 + <li><strong>Retention:</strong> for as long as your account is active.</li> 78 + </ul> 79 + 80 + <h3>4.3 Cached content (Cloudflare KV)</h3> 81 + <p> 82 + To speed up rendering and reduce load on third-party APIs, we cache public PDS records, profile 83 + data, and third-party card data in Cloudflare Workers KV. Typical cache lifetimes range from 1 84 + hour (e.g. Last.fm listens, events) to 24 hours (profile and PDS records), up to 30 days for 85 + generated Open Graph images. Cached entries expire automatically. 86 + </p> 87 + <ul> 88 + <li><strong>Purpose:</strong> performance and reduction of third-party API load.</li> 89 + <li> 90 + <strong>Legal basis:</strong> Art. 6(1)(f) GDPR (legitimate interest in operating the Service efficiently). 91 + </li> 92 + <li><strong>Retention:</strong> as stated above; expires automatically.</li> 93 + </ul> 94 + 95 + <h3>4.4 Server and request logs</h3> 96 + <p> 97 + Cloudflare, our hosting provider, processes standard request metadata (IP address, user agent, 98 + timestamp, URL) for security, abuse prevention, and delivery of the Service. We do not maintain 99 + long-term logs ourselves; Cloudflare's default retention applies (typically a few days). 100 + </p> 101 + <ul> 102 + <li> 103 + <strong>Legal basis:</strong> Art. 6(1)(f) GDPR (legitimate interest in a secure and reliable service). 104 + </li> 105 + </ul> 106 + 107 + <h3>4.5 Aggregate analytics</h3> 108 + <p> 109 + We collect cookieless, aggregate pageview statistics using Cloudflare Workers Analytics Engine. 110 + Each pageview records the visited page, a coarse country code derived from the visitor's IP 111 + address (the IP itself is not stored by us), and the hostname of the referring website if any. 112 + Individual visitors are not tracked across pages or sessions, and no persistent identifier is 113 + stored on your device. 114 + </p> 115 + <ul> 116 + <li><strong>Purpose:</strong> understanding aggregate site usage to guide improvements.</li> 117 + <li> 118 + <strong>Legal basis:</strong> Art. 6(1)(f) GDPR (legitimate interest in measuring usage in a privacy-preserving 119 + way). 120 + </li> 121 + <li> 122 + <strong>Retention:</strong> up to 90 days, after which Cloudflare automatically deletes the data. 123 + </li> 124 + </ul> 125 + 126 + <h3>4.6 Third-party embeds</h3> 127 + <p> 128 + Cards can embed content from third parties (Bluesky, YouTube, GitHub, Last.fm, map providers, 129 + etc.). When a page containing such a card is loaded, the visitor's browser makes requests to those 130 + providers, which may process the visitor's IP address and other request metadata under their own 131 + privacy policies. We do not control that processing. 132 + </p> 133 + 134 + <h2>5. Recipients and Processors</h2> 135 + <ul> 136 + <li> 137 + <strong>Cloudflare, Inc.</strong> (USA) — hosting, CDN, Workers runtime, KV storage. Acts as a processor 138 + under Art. 28 GDPR based on Cloudflare's Data Processing Addendum. 139 + </li> 140 + <li> 141 + <strong>Your PDS provider</strong> (the operator of your atmosphere account's data server) — receives 142 + your records when you save them. 143 + </li> 144 + <li> 145 + <strong>Third-party card providers</strong> (e.g. Bluesky, GitHub, YouTube, Last.fm, map providers) 146 + — when embedded, they receive visitor request data directly. 147 + </li> 148 + </ul> 149 + 150 + <h2>6. International Data Transfers</h2> 151 + <p>Cloudflare processes data in the United States and globally. Transfers are safeguarded by:</p> 152 + <ul> 153 + <li> 154 + Cloudflare's certification under the <strong>EU-US Data Privacy Framework</strong> (adequacy decision 155 + of the European Commission of 10 July 2023); and 156 + </li> 157 + <li>EU Standard Contractual Clauses (Art. 46(2)(c) GDPR) as a fallback.</li> 158 + </ul> 159 + <p> 160 + Third-party card providers process data in their own jurisdictions under their respective 161 + safeguards. 162 + </p> 163 + 164 + <h2>7. Cookies and Similar Technologies</h2> 165 + <p> 166 + We use only strictly necessary cookies / local storage entries for authentication (&sect; 25(2) 167 + Nr. 2 TDDDG). We do not use cookies or tracking technologies for analytics, advertising, or 168 + profiling. No consent banner is required. 169 + </p> 170 + 171 + <h2>8. Your Rights</h2> 172 + <p>Under the GDPR you have the following rights:</p> 173 + <ul> 174 + <li>Right of access (Art. 15 GDPR)</li> 175 + <li>Right to rectification (Art. 16 GDPR)</li> 176 + <li>Right to erasure / "to be forgotten" (Art. 17 GDPR)</li> 177 + <li>Right to restriction of processing (Art. 18 GDPR)</li> 178 + <li>Right to data portability (Art. 20 GDPR)</li> 179 + <li> 180 + Right to object to processing based on legitimate interests (Art. 21 GDPR) &mdash; including a 181 + general right to object at any time, on grounds relating to your particular situation 182 + </li> 183 + <li> 184 + Right not to be subject to automated decision-making (Art. 22 GDPR) &mdash; we do not carry out 185 + any such processing 186 + </li> 187 + <li> 188 + Right to withdraw consent at any time, where processing is based on consent (Art. 7(3) GDPR) 189 + &mdash; we currently do not rely on consent for any processing 190 + </li> 191 + </ul> 192 + <p> 193 + To exercise any of these rights, email <a href="mailto:hello@blento.app">hello@blento.app</a>. 194 + </p> 195 + 196 + <h2>9. Right to Lodge a Complaint</h2> 197 + <p> 198 + You have the right to lodge a complaint with a supervisory authority (Art. 77 GDPR). The competent 199 + authority for us is: 200 + </p> 201 + <p> 202 + Berliner Beauftragte f&uuml;r Datenschutz und Informationsfreiheit (BlnBDI)<br /> 203 + Alt-Moabit 59&ndash;61, 10555 Berlin, Germany<br /> 204 + <a href="https://www.datenschutz-berlin.de" target="_blank" rel="noopener" 205 + >www.datenschutz-berlin.de</a 206 + > 207 + </p> 208 + <p> 209 + You may also lodge a complaint with any other supervisory authority, in particular in the Member 210 + State of your habitual residence. 211 + </p> 212 + 213 + <h2>10. Data Deletion</h2> 214 + <p> 215 + Because your site content lives in your PDS, you can delete it directly via any atproto client or 216 + by editing your site. Cached copies on our side expire automatically. For account deletion or 217 + requests covering data beyond what you can delete yourself, email 218 + <a href="mailto:hello@blento.app">hello@blento.app</a>. 219 + </p> 220 + 221 + <h2>11. Children</h2> 222 + <p> 223 + The Service is not directed at children. In Germany, the consent of a holder of parental 224 + responsibility is required for children under the age of 16 (Art. 8 GDPR). We do not knowingly 225 + process personal data of children under 16. 226 + </p> 227 + 228 + <h2>12. What We Don't Do</h2> 229 + <ul> 230 + <li>We do not sell your personal data.</li> 231 + <li>We do not run advertising or cross-site tracking.</li> 232 + <li>We do not build behavioural profiles of you.</li> 233 + <li>We do not carry out automated decision-making in the sense of Art. 22 GDPR.</li> 234 + </ul> 235 + 236 + <h2>13. Changes</h2> 237 + <p> 238 + We may update this policy. Material changes will be announced on this page, and where they 239 + materially affect your rights we will give reasonable advance notice. 240 + </p> 241 + 242 + <h2>14. Contact</h2> 243 + <p>Questions about this policy? Reach out via:</p> 244 + <ul> 245 + <li>Email: <a href="mailto:hello@blento.app">hello@blento.app</a></li> 246 + <li> 247 + Bluesky: 248 + <a href="https://bsky.app/profile/blento.app" target="_blank" rel="noopener">@blento.app</a> 249 + </li> 250 + </ul>
+183
src/routes/(legal)/terms/+page.svelte
··· 1 + <svelte:head> 2 + <title>Terms of Service &middot; blento</title> 3 + <meta name="description" content="Terms of Service for blento." /> 4 + </svelte:head> 5 + 6 + <h1>Terms of Service</h1> 7 + <p><em>Last updated: April 18, 2026</em></p> 8 + 9 + <h2>1. Scope and Acceptance</h2> 10 + <p> 11 + These Terms of Service ("Terms") govern the use of blento ("the Service"), operated by Florian 12 + Killius, Berlin, Germany ("we", "us"). Contact details and the full operator address are set out 13 + in our <a href="/imprint">Imprint</a>. 14 + </p> 15 + <p> 16 + By signing in to or otherwise using the Service, you confirm that you have read these Terms and 17 + agree to be bound by them. If you do not agree, please do not use the Service. These Terms are 18 + made available in full on this page at all times and you can save or print them before you 19 + proceed. 20 + </p> 21 + 22 + <h2>2. Definitions</h2> 23 + <ul> 24 + <li> 25 + <strong>AT Protocol (atproto):</strong> the open decentralized protocol that the Service is 26 + built on. See 27 + <a href="https://atproto.com" target="_blank" rel="noopener">atproto.com</a>. 28 + </li> 29 + <li> 30 + <strong>Atmosphere:</strong> the open network of applications and services built on the AT Protocol. 31 + Bluesky is one application in the atmosphere; blento is another. 32 + </li> 33 + <li> 34 + <strong>Atmosphere account:</strong> your identity on the atmosphere, represented by a DID and a handle. 35 + </li> 36 + <li> 37 + <strong>Personal Data Server (PDS):</strong> the server that hosts the data for your atmosphere account. 38 + Content you create on blento is stored in your PDS and is the canonical copy. 39 + </li> 40 + </ul> 41 + 42 + <h2>3. The Service</h2> 43 + <p> 44 + blento is a bento-grid website builder powered by the AT Protocol. Content you create is stored in 45 + your PDS under your atmosphere account; blento does not host the primary copy of that data. The 46 + Service is provided free of charge; we reserve the right to change, suspend, or discontinue 47 + features with reasonable notice. 48 + </p> 49 + 50 + <h2>4. Your Account</h2> 51 + <p> 52 + You authenticate via atproto OAuth using your atmosphere account. You are responsible for 53 + maintaining the security of your account and for all activity that occurs through it. You must be 54 + at least 16 years old to use the Service, or you must have the consent of a holder of parental 55 + responsibility. 56 + </p> 57 + 58 + <h2>5. User Content</h2> 59 + <p> 60 + You retain all ownership rights in the content you create. You are solely responsible for the 61 + content you publish and confirm you have the necessary rights to share it. By publishing content 62 + through the Service, you grant us a worldwide, non-exclusive, royalty-free licence to host, cache, 63 + reproduce, and display that content solely for the purpose of operating the Service, for as long 64 + as you keep it published. 65 + </p> 66 + <p> 67 + Content that is illegal, infringes third-party rights, or violates our acceptable-use rules may be 68 + removed from public rendering on the Service at our discretion. Because your canonical copy lives 69 + in your PDS, our removal affects only how blento renders it; you retain access to the data itself. 70 + </p> 71 + 72 + <h2>6. Acceptable Use</h2> 73 + <p>You agree not to use the Service to:</p> 74 + <ul> 75 + <li>Violate any applicable law or regulation;</li> 76 + <li>Publish content that infringes intellectual-property or privacy rights;</li> 77 + <li>Distribute malware, phishing, or other harmful material;</li> 78 + <li>Harass, threaten, or harm others, or incite hatred or violence;</li> 79 + <li>Attempt to disrupt, overload, or compromise the Service or circumvent access controls.</li> 80 + </ul> 81 + 82 + <h2>7. Reporting Illegal Content (DSA)</h2> 83 + <p> 84 + In accordance with Article 16 of the EU Digital Services Act (Regulation (EU) 2022/2065), anyone 85 + may notify us of content they consider illegal by emailing 86 + <a href="mailto:hello@blento.app">hello@blento.app</a> with: 87 + </p> 88 + <ul> 89 + <li>a substantiated explanation of why you believe the content is illegal;</li> 90 + <li>the exact URL(s);</li> 91 + <li> 92 + your name and email (unless reporting offences under Arts. 3&ndash;7 of Directive 2011/93/EU); 93 + </li> 94 + <li>a good-faith statement that the information is accurate and complete.</li> 95 + </ul> 96 + <p> 97 + We will acknowledge receipt, assess the report in good faith, and act expeditiously where 98 + appropriate. Where feasible, we will provide a statement of reasons for any action taken. 99 + </p> 100 + 101 + <h2>8. Disclaimer</h2> 102 + <p> 103 + The Service is provided as is and as available. We do not warrant that the Service will be 104 + uninterrupted, error-free, or that cached third-party content will be current or accurate. 105 + Statutory warranty rights under German law remain unaffected. 106 + </p> 107 + 108 + <h2>9. Liability</h2> 109 + <p> 110 + We are liable without limitation for damages caused by intent or gross negligence, for injury to 111 + life, body, or health, under the German Product Liability Act (ProdHaftG), to the extent of any 112 + warranty we have expressly given, and in any other case of mandatory statutory liability. 113 + </p> 114 + <p> 115 + For damages caused by ordinary negligence, we are liable only where we breach an essential 116 + contractual obligation &mdash; that is, an obligation whose fulfilment makes the proper 117 + performance of the contract possible in the first place and on whose observance you may regularly 118 + rely ("Kardinalpflichten"). Our liability in such cases is limited to damages that are typical for 119 + this kind of contract and reasonably foreseeable. 120 + </p> 121 + <p>Any further liability for ordinary negligence is excluded.</p> 122 + <p> 123 + Because the Service is provided free of charge, the typical use case involves no paid services. 124 + Nothing in this section restricts liability that cannot be restricted or excluded under mandatory 125 + law. 126 + </p> 127 + 128 + <h2>10. Termination</h2> 129 + <p> 130 + You may stop using the Service at any time. On request to 131 + <a href="mailto:hello@blento.app">hello@blento.app</a> we will delete any data we hold about your account 132 + that cannot be deleted by you directly via your PDS. We may suspend or terminate accounts that violate 133 + these Terms, with notice where reasonably possible. 134 + </p> 135 + 136 + <h2>11. Changes to These Terms</h2> 137 + <p> 138 + We may update these Terms where necessary, for example to reflect legal changes or new features. 139 + We will notify you of material changes at least 30 days before they take effect, by posting the 140 + updated Terms on this page with a new "last updated" date and, where you have an active account 141 + with a reachable contact, by a reasonable additional means. If you do not object within 30 days 142 + after notification, the updated Terms become effective; you have the right to terminate your use 143 + of the Service during this period if you do not agree. 144 + </p> 145 + 146 + <h2>12. Governing Law and Venue</h2> 147 + <p> 148 + These Terms are governed by the laws of the Federal Republic of Germany, excluding the UN 149 + Convention on Contracts for the International Sale of Goods. If you are a consumer with habitual 150 + residence in another EU Member State, mandatory consumer-protection rules of that state remain 151 + unaffected. 152 + </p> 153 + <p> 154 + Exclusive place of jurisdiction for all disputes arising from or in connection with these Terms is 155 + Berlin, Germany, to the extent you are a merchant, a legal person under public law, or a special 156 + fund under public law, or you have no general place of jurisdiction in Germany. For consumers, the 157 + statutory places of jurisdiction apply. 158 + </p> 159 + 160 + <h2>13. Consumer Dispute Resolution</h2> 161 + <p> 162 + The European Commission provides an online dispute resolution platform (OS): 163 + <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener" 164 + >https://ec.europa.eu/consumers/odr</a 165 + >. We are neither obliged nor willing to participate in a dispute-resolution procedure before a 166 + consumer arbitration board (&sect; 36 VSBG). 167 + </p> 168 + 169 + <h2>14. Severability</h2> 170 + <p> 171 + Should any provision of these Terms be or become invalid or unenforceable, the validity of the 172 + remaining provisions shall not be affected. 173 + </p> 174 + 175 + <h2>15. Contact</h2> 176 + <p>Questions about these Terms? Reach out via:</p> 177 + <ul> 178 + <li>Email: <a href="mailto:hello@blento.app">hello@blento.app</a></li> 179 + <li> 180 + Bluesky: 181 + <a href="https://bsky.app/profile/blento.app" target="_blank" rel="noopener">@blento.app</a> 182 + </li> 183 + </ul>
+10
src/routes/[[actor=actor]]/(pages)/+layout.server.ts
··· 3 3 import { error } from '@sveltejs/kit'; 4 4 import { createCache } from '$lib/cache'; 5 5 import { getActor } from '$lib/actor.js'; 6 + import { logPageview } from '$lib/analytics'; 6 7 7 8 export async function load({ params, platform, request, locals, route, setHeaders }) { 8 9 if (env.PUBLIC_IS_SELFHOSTED) error(404); ··· 26 27 }); 27 28 } else { 28 29 setHeaders({ 'Cache-Control': 'private, no-store' }); 30 + } 31 + 32 + if (!isInteractiveRoute) { 33 + logPageview(platform, { 34 + did: data.did, 35 + handle: data.handle, 36 + page: params.page ?? 'self', 37 + request 38 + }); 29 39 } 30 40 31 41 return data;
+54
src/routes/[[actor=actor]]/api/reindex/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { contrail, ensureInit } from '$lib/contrail'; 3 + import { listRecords } from '$lib/atproto/methods'; 4 + import { collections } from '$lib/atproto/settings'; 5 + import { getActor } from '$lib/actor'; 6 + import type { Did } from '@atcute/lexicons'; 7 + import { dev } from '$app/environment'; 8 + 9 + export async function GET({ params, platform, locals, request }) { 10 + if (!locals.did) { 11 + return json({ error: 'Not authenticated' }, { status: 401 }); 12 + } 13 + 14 + const actor = await getActor({ request, paramActor: params.actor, platform }); 15 + if (!actor) { 16 + return json({ error: 'Actor not found' }, { status: 404 }); 17 + } 18 + 19 + if (actor !== locals.did && !dev) { 20 + return json({ error: 'You can only reindex your own account' }, { status: 403 }); 21 + } 22 + 23 + const db = platform?.env?.DB; 24 + if (!db) { 25 + return json({ error: 'Database not available' }, { status: 503 }); 26 + } 27 + 28 + const did = actor as Did; 29 + await ensureInit(db); 30 + 31 + // Fetch all records from the user's PDS across all tracked collections 32 + const uris: string[] = []; 33 + 34 + await Promise.all( 35 + collections.map(async (collection) => { 36 + try { 37 + const records = await listRecords({ did, collection, limit: 0 }); 38 + for (const record of records) { 39 + uris.push(record.uri); 40 + } 41 + } catch { 42 + // Collection may not exist for this user — skip 43 + } 44 + }) 45 + ); 46 + 47 + if (uris.length === 0) { 48 + return json({ ok: true, indexed: 0, deleted: 0 }); 49 + } 50 + 51 + const result = await contrail.notify(uris, db); 52 + 53 + return json({ ok: true, indexed: result.indexed, deleted: result.deleted }); 54 + }
+6
wrangler.jsonc
··· 64 64 "remote": true 65 65 } 66 66 ], 67 + "analytics_engine_datasets": [ 68 + { 69 + "binding": "ANALYTICS", 70 + "dataset": "blento_pageviews" 71 + } 72 + ], 67 73 "triggers": { 68 74 "crons": ["*/5 * * * *"] 69 75 }