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

Configure Feed

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

commit

Florian 0279881e f21037fa

+677 -245
+45
docs/todo/sections-launch.md
··· 1 + # Sections: launch TODO 2 + 3 + Everything needed before sections can be enabled for all users (remove feature flag). 4 + 5 + ## Critical / Blocking 6 + 7 + - [ ] **Page copy must include sections** — the copy route (`/p/[[page]]/copy/+page.svelte`) only copies cards today; section records are silently dropped, so the copied page loses all structure and non-grid section types 8 + - [ ] **Migration for existing users** — `ensureSections()` in `migrate.ts` synthesises a single grid section for legacy pages, but verify this works cleanly for users who already have cards with no `sectionId` across multiple pages 9 + 10 + ## Section editing UI gaps 11 + 12 + - [ ] **Gallery section: column count control** — `sectionData.columns` is read but there's no UI to change it 13 + - [ ] **Gallery section: gap control** — `sectionData.gap` is read but there's no UI to change it 14 + - [ ] **Text section: alignment/size controls** — `textAlign` and `textSize` are in `sectionData` but no editing UI exposes them 15 + - [ ] **Row section: scroll mode control** — `sectionData.scrollMode` (`'scroll'` | `'fit'`) has no UI toggle 16 + - [ ] **Section renaming** — `section.name` field exists on the record but there's no input to edit it (SectionsModal only displays the name) 17 + - [ ] **Section duplication** — no way to duplicate a section (with or without its cards) 18 + 19 + ## Cross-section interactions 20 + 21 + - [ ] **Move cards between sections** — cards are locked to the section they were created in; no drag-to-another-section or "move to section" action exists 22 + - [ ] **Section collapse/expand in editor** — all sections are always fully expanded; pages with many sections get unwieldy 23 + 24 + ## Section styling / customisation 25 + 26 + - [ ] **Background color per section** — no way to set a section background (color, gradient, image) 27 + - [ ] **Padding / spacing controls** — no per-section padding or vertical gap control 28 + - [ ] **Section max-height** — only HeroSection enforces a height (`100dvh`); other sections grow unbounded 29 + 30 + ## Mobile 31 + 32 + - [ ] **Mobile editing parity** — TextSection and RowSection don't have mobile-specific editing controls; the mobile editing warning modal is still shown as "experimental" 33 + 34 + ## Polish / UX 35 + 36 + - [ ] **Empty state for TextSection editing** — no placeholder or guidance when the text content is blank 37 + - [ ] **SectionsModal improvements** — modal is functional but minimal; consider drag-to-reorder instead of up/down buttons, section type icons, and inline rename 38 + - [ ] **AddSectionButton visibility** — button is `opacity-0` until parent hover; may be hard to discover on touch devices 39 + - [ ] **Confirm before deleting a section with cards** — currently deletes immediately; should warn if the section contains cards 40 + 41 + ## Deferred / post-launch 42 + 43 + - [ ] **External data sources** — see `docs/todo/external-section-sources.md` 44 + - [ ] **Section-level permissions / visibility** — e.g. hide a section from non-authenticated viewers 45 + - [ ] **Section templates** — pre-built section layouts users can pick from when adding a section
+53
docs/todo/subpages.md
··· 1 + # Subpages — path to usability 2 + 3 + Subpages already work record- and route-wise (`app.blento.page`, routes under `/[actor]/p/[page]`), but there is no UI to create them, no way to navigate between them, and several metadata gaps. This doc lists everything needed to make subpages a usable first-class feature. 4 + 5 + ## Creation & management 6 + 7 + - [ ] Create-subpage UI 8 + - Pick slug → create `app.blento.page/blento.{slug}` record with empty sections 9 + - Initial name/description fields 10 + - [ ] Rename (display name) — write-back to `app.blento.page` 11 + - [ ] Change slug 12 + - rkey is immutable, so: create new record + migrate cards/sections (`page` field) + delete old record 13 + - [ ] Delete subpage 14 + - Cascade delete sections and cards where `page === 'blento.{slug}'` 15 + - [ ] Manage subpages panel (settings? edit bar dropdown?) 16 + - [ ] Slug validation 17 + - Reserved: `self`, `edit`, `copy` 18 + - Character restrictions matching rkey rules 19 + 20 + ## Discovery & navigation 21 + 22 + - [ ] Navbar / nav links on the main page (and likely all pages) 23 + - [ ] "Link to subpage" card type (picks a subpage, renders name/icon) 24 + - Arguably shares a data source with the navbar 25 + - [ ] Back-to-home affordance on subpages (logo/name tap? explicit home link?) 26 + - [ ] Subpages in sitemap / Contrail indexing — probably yes so shared links resolve fresh 27 + 28 + ## OG / metadata per page 29 + 30 + - [ ] Per-subpage OG image 31 + - `og-new.png` endpoint currently keys on actor only; include `page` in cache key + screenshot URL 32 + - [ ] Wire per-page title/description/icon from `app.blento.page` into `<Head>` on subpage routes 33 + 34 + ## Editing experience 35 + 36 + - [ ] Entry point from main-page edit → switch to editing a subpage (page switcher in edit mode) 37 + - [ ] Verify self-copy flow for duplicating one of your own pages 38 + 39 + ## Navbar record placement — decision 40 + 41 + Three options for where navigation links live: 42 + 43 + - **A.** `site.standard.publication/blento.self` preferences — simplest, one record. But nav isn't really a "preference." 44 + - **B.** Extend `app.blento.page/blento.self` with a `navigation` array — clean, no new lexicon, couples nav to the main-page record. 45 + - **C.** New `app.blento.navigation/self` record — cleanest semantically, extra lexicon to maintain. 46 + 47 + **Picked: B.** Main-page publication record is already the canonical site config, nav is sitewide and only edited from the main page. If per-subpage overrides ever become a need, migrate then. 48 + 49 + ## Open questions (not required for v1) 50 + 51 + - Page privacy / password gating 52 + - Per-page theme overrides 53 + - Page templates / duplicate-as-template
+1 -1
src/lib/sections/GridSection/GridSection.svelte
··· 14 14 ); 15 15 </script> 16 16 17 - <div class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 17 + <div class="@container/grid relative col-span-3 px-2 pt-4 pb-8 lg:px-8 lg:pt-8"> 18 18 {#each items.toSorted(sortItems) as item (item.id)} 19 19 <GridBaseCard {item}> 20 20 <Card {item} />
+2 -2
src/lib/sections/feature-flag.ts
··· 6 6 * PUBLIC_SECTIONS_ENABLED env flag. Useful for dogfooding with specific users. 7 7 */ 8 8 const ALLOWED_DIDS: readonly string[] = [ 9 - // flo-bit.dev 10 - 'did:plc:257wekqxg4hyapkq6k47igmp' 9 + // flo-bit.dev 10 + 'did:plc:257wekqxg4hyapkq6k47igmp' 11 11 ]; 12 12 13 13 /**
+39 -12
src/lib/website/Account.svelte
··· 16 16 </script> 17 17 18 18 {#if user.isLoggedIn && user.profile} 19 - <div class="fixed top-4 right-4 z-20"> 20 - <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 19 + <div 20 + class="bg-base-100 dark:bg-base-950 border-base-200 dark:border-base-800 fixed top-3 right-3 z-20 flex flex-col items-center gap-1 rounded-full border p-1.5 shadow-md" 21 + > 22 + <!-- Avatar with popover --> 23 + <Popover 24 + side="left" 25 + sideOffset={8} 26 + bind:open={settingsPopoverOpen} 27 + class="bg-base-100 dark:bg-base-900" 28 + > 21 29 {#snippet child({ props })} 22 - <button {...props}> 23 - <Avatar src={user.profile?.avatar} alt="" class="size-15 cursor-pointer rounded-full" /> 30 + <button {...props} class="cursor-pointer"> 31 + <Avatar src={user.profile?.avatar} alt="" class="size-8 rounded-full" /> 24 32 </button> 25 33 {/snippet} 26 34 ··· 34 42 > 35 43 {/if} 36 44 37 - <Button 38 - variant="ghost" 39 - onclick={() => { 40 - settingsPopoverOpen = false; 41 - settingsOverlayState.show(); 42 - }}>Settings</Button 43 - > 44 - 45 45 <Button variant="ghost" onclick={logout}>Logout</Button> 46 46 </div> 47 47 </Popover> 48 + 49 + <!-- Settings button --> 50 + <button 51 + type="button" 52 + class="text-base-500 hover:text-base-700 dark:text-base-400 dark:hover:text-base-200 flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors" 53 + onclick={() => settingsOverlayState.show()} 54 + > 55 + <svg 56 + xmlns="http://www.w3.org/2000/svg" 57 + fill="none" 58 + viewBox="0 0 24 24" 59 + stroke-width="1.5" 60 + stroke="currentColor" 61 + class="size-4" 62 + > 63 + <path 64 + stroke-linecap="round" 65 + stroke-linejoin="round" 66 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 67 + /> 68 + <path 69 + stroke-linecap="round" 70 + stroke-linejoin="round" 71 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 72 + /> 73 + </svg> 74 + </button> 48 75 </div> 49 76 {/if}
-123
src/lib/website/Controls.svelte
··· 1 - <script lang="ts"> 2 - import { SelectThemePopover } from '$lib/components/select-theme'; 3 - import { getHideProfileSection, getProfilePosition } from '$lib/helper'; 4 - import type { WebsiteData } from '$lib/types'; 5 - import { Button } from '@foxui/core'; 6 - import { getIsMobile } from './context'; 7 - 8 - let { data = $bindable() }: { data: WebsiteData } = $props(); 9 - 10 - let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 11 - let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 12 - 13 - function updateTheme(newAccent: string, newBase: string) { 14 - data.publication.preferences ??= {}; 15 - data.publication.preferences.accentColor = newAccent; 16 - data.publication.preferences.baseColor = newBase; 17 - data = { ...data }; 18 - } 19 - 20 - let profilePosition = $derived(getProfilePosition(data)); 21 - 22 - function toggleProfilePosition() { 23 - data.publication.preferences ??= {}; 24 - data.publication.preferences.profilePosition = profilePosition === 'side' ? 'top' : 'side'; 25 - data = { ...data }; 26 - } 27 - 28 - let isMobile = getIsMobile(); 29 - </script> 30 - 31 - <div class={['fixed top-2 left-14 z-20 flex gap-2']}> 32 - <Button 33 - size="icon" 34 - onclick={() => { 35 - data.publication.preferences ??= {}; 36 - data.publication.preferences.hideProfileSection = 37 - !data.publication.preferences?.hideProfileSection; 38 - data = { ...data }; 39 - }} 40 - variant="ghost" 41 - > 42 - {#if !getHideProfileSection(data)} 43 - <svg 44 - xmlns="http://www.w3.org/2000/svg" 45 - fill="none" 46 - viewBox="0 0 24 24" 47 - stroke-width="1.5" 48 - stroke="currentColor" 49 - class="size-5!" 50 - > 51 - <path 52 - stroke-linecap="round" 53 - stroke-linejoin="round" 54 - d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" 55 - /> 56 - </svg> 57 - {:else} 58 - <svg 59 - xmlns="http://www.w3.org/2000/svg" 60 - fill="none" 61 - viewBox="0 0 24 24" 62 - stroke-width="1.5" 63 - stroke="currentColor" 64 - class="size-5!" 65 - > 66 - <path 67 - stroke-linecap="round" 68 - stroke-linejoin="round" 69 - d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" 70 - /> 71 - <path 72 - stroke-linecap="round" 73 - stroke-linejoin="round" 74 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 75 - /> 76 - </svg> 77 - {/if} 78 - </Button> 79 - 80 - <!-- Position toggle button (desktop only) --> 81 - {#if !isMobile() && !getHideProfileSection(data)} 82 - <Button size="icon" type="button" onclick={toggleProfilePosition} variant="ghost"> 83 - {#if profilePosition === 'side'} 84 - <svg 85 - xmlns="http://www.w3.org/2000/svg" 86 - fill="none" 87 - viewBox="0 0 24 24" 88 - stroke-width="1.5" 89 - stroke="currentColor" 90 - class="size-5!" 91 - > 92 - <path 93 - stroke-linecap="round" 94 - stroke-linejoin="round" 95 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 96 - /> 97 - </svg> 98 - {:else} 99 - <svg 100 - xmlns="http://www.w3.org/2000/svg" 101 - fill="none" 102 - viewBox="0 0 24 24" 103 - stroke-width="1.5" 104 - stroke="currentColor" 105 - class="size-5!" 106 - > 107 - <path 108 - stroke-linecap="round" 109 - stroke-linejoin="round" 110 - d="m19.5 4.5-15 15m0 0h11.25m-11.25 0V8.25" 111 - /> 112 - </svg> 113 - {/if} 114 - </Button> 115 - {/if} 116 - 117 - <!-- Theme selection --> 118 - <SelectThemePopover 119 - {accentColor} 120 - {baseColor} 121 - onchanged={(newAccent, newBase) => updateTheme(newAccent, newBase)} 122 - /> 123 - </div>
+6 -26
src/lib/website/EditBar.svelte
··· 32 32 onsetsize, 33 33 allowRotate = false, 34 34 onrotate, 35 - showSectionsModal 35 + sidebarOpen = false 36 36 }: { 37 37 data: WebsiteData; 38 38 ··· 58 58 onsetsize?: (w: number, h: number) => void; 59 59 allowRotate?: boolean; 60 60 onrotate?: (delta: number) => void; 61 - showSectionsModal?: () => void; 61 + sidebarOpen?: boolean; 62 62 } = $props(); 63 63 64 64 let linkPopoverOpen = $state(false); ··· 156 156 /> 157 157 158 158 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 159 - <div class="fixed right-0 bottom-2 left-0 z-50 flex flex-col items-center gap-1.5 px-4"> 159 + <div 160 + class="fixed right-0 bottom-2 z-50 flex flex-col items-center gap-1.5 px-4 transition-[left] duration-200" 161 + style="left: {sidebarOpen ? '18rem' : '0'}" 162 + > 160 163 {#if showEditControls} 161 164 <Navbar 162 165 class="dark:bg-base-950 bg-base-100 relative top-auto mx-8 mt-0 h-11 w-[calc(100%-4rem)] max-w-2xl rounded-full px-4" ··· 403 406 </Button> 404 407 </div> 405 408 <div class="flex items-center gap-2"> 406 - {#if showSectionsModal} 407 - <Button 408 - size="icon" 409 - variant="ghost" 410 - class="backdrop-blur-none" 411 - onclick={showSectionsModal} 412 - > 413 - <svg 414 - xmlns="http://www.w3.org/2000/svg" 415 - viewBox="0 0 24 24" 416 - fill="none" 417 - stroke="currentColor" 418 - stroke-width="2" 419 - stroke-linecap="round" 420 - stroke-linejoin="round" 421 - class="size-4" 422 - > 423 - <rect x="3" y="3" width="18" height="18" rx="2" /> 424 - <path d="M3 9h18" /> 425 - <path d="M3 15h18" /> 426 - </svg> 427 - </Button> 428 - {/if} 429 409 <Toggle 430 410 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 431 411 bind:pressed={showingMobileView}
+2 -2
src/lib/website/EditableProfile.svelte
··· 60 60 > 61 61 <div 62 62 class={[ 63 - 'flex flex-col gap-4 pt-16 pb-4', 64 - profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 63 + 'flex flex-col gap-4 pt-10 pb-0', 64 + profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-12' 65 65 ]} 66 66 > 67 67 <!-- Avatar with edit capability -->
+55 -21
src/lib/website/EditableWebsite.svelte
··· 30 30 import { user } from '$lib/atproto'; 31 31 import * as TID from '@atcute/tid'; 32 32 import { launchConfetti } from '@foxui/visual'; 33 - import Controls from './Controls.svelte'; 34 - import SectionsModal from './SectionsModal.svelte'; 35 - import AddSectionButton from './AddSectionButton.svelte'; 33 + 34 + import SectionsSidebar from './SectionsSidebar.svelte'; 35 + 36 36 import { createImageCard, createVideoCard } from './file-processing'; 37 37 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 38 38 import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte'; ··· 269 269 } 270 270 } 271 271 272 - let showSectionsModal = $state(false); 272 + let showSectionsSidebar = $state(false); 273 273 274 274 function addSection(sectionType: string, afterIndex?: number) { 275 275 const sorted = sections.toSorted((a, b) => a.index - b.index); ··· 391 391 /> 392 392 393 393 <Account bind:data /> 394 + 395 + {#if sectionsEditingEnabled} 396 + <button 397 + type="button" 398 + class="bg-base-100 dark:bg-base-950 border-base-200 dark:border-base-800 text-base-600 dark:text-base-400 hover:text-base-800 dark:hover:text-base-200 fixed top-3 left-3 z-20 flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium shadow-md transition-colors" 399 + onclick={() => (showSectionsSidebar = true)} 400 + > 401 + <svg 402 + xmlns="http://www.w3.org/2000/svg" 403 + viewBox="0 0 24 24" 404 + fill="none" 405 + stroke="currentColor" 406 + stroke-width="2" 407 + stroke-linecap="round" 408 + stroke-linejoin="round" 409 + class="size-4" 410 + > 411 + <rect x="3" y="3" width="18" height="18" rx="2" /> 412 + <path d="M3 9h18" /> 413 + <path d="M3 15h18" /> 414 + </svg> 415 + Layout 416 + <svg 417 + xmlns="http://www.w3.org/2000/svg" 418 + viewBox="0 0 24 24" 419 + fill="none" 420 + stroke="currentColor" 421 + stroke-width="2" 422 + stroke-linecap="round" 423 + stroke-linejoin="round" 424 + class="size-3" 425 + > 426 + <path d="m6 9 6 6 6-6" /> 427 + </svg> 428 + </button> 429 + {/if} 430 + 394 431 <SettingsOverlay bind:data publicationUrl={data.publication?.url} /> 395 432 396 433 <Context {data} isEditing={true}> ··· 412 449 addLink(url, cardDef); 413 450 }} 414 451 /> 415 - 416 - <Controls bind:data /> 417 452 418 453 {#if showingMobileView} 419 454 <div ··· 443 478 page={data.page} 444 479 /> 445 480 446 - <SectionsModal 447 - bind:open={showSectionsModal} 448 - bind:sections 449 - ondelete={deleteSection} 450 - onlayoutchange={() => (hasUnsavedChanges = true)} 451 - /> 481 + {#if sectionsEditingEnabled} 482 + <SectionsSidebar 483 + bind:open={showSectionsSidebar} 484 + bind:sections 485 + bind:activeSectionId 486 + bind:data 487 + ondelete={deleteSection} 488 + onlayoutchange={() => (hasUnsavedChanges = true)} 489 + onadd={(type) => addSection(type)} 490 + /> 491 + {/if} 452 492 453 493 <Modal open={showMobileWarning} closeButton={false}> 454 494 <div class="flex flex-col items-center gap-4 text-center"> ··· 489 529 <div 490 530 class={[ 491 531 'pointer-events-none relative mx-auto max-w-lg', 492 - !getHideProfileSection(data) && getProfilePosition(data) === 'side' 532 + (!getHideProfileSection(data) && getProfilePosition(data) === 'side') || 533 + (showSectionsSidebar && getHideProfileSection(data)) 493 534 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 494 535 : '@5xl/wrapper:max-w-4xl' 495 536 ]} ··· 524 565 }} 525 566 /> 526 567 {/if} 527 - {#if sectionsEditingEnabled} 528 - <AddSectionButton onadd={(type) => addSection(type, i)} /> 529 - {/if} 530 568 {/each} 531 569 <div class="h-20"></div> 532 570 </div> ··· 588 626 onLayoutChanged(); 589 627 } 590 628 }} 591 - showSectionsModal={sectionsEditingEnabled 592 - ? () => { 593 - showSectionsModal = true; 594 - } 595 - : undefined} 629 + sidebarOpen={showSectionsSidebar} 596 630 /> 597 631 598 632 <Toaster />
+2 -2
src/lib/website/Profile.svelte
··· 49 49 > 50 50 <div 51 51 class={[ 52 - 'flex flex-col gap-4 pt-16 pb-4', 53 - profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-24' 52 + 'flex flex-col gap-4 pt-10 pb-0', 53 + profilePosition === 'side' && '@5xl/wrapper:h-screen @5xl/wrapper:pt-12' 54 54 ]} 55 55 > 56 56 <a
+27 -5
src/lib/website/Pronouns.svelte
··· 61 61 </script> 62 62 63 63 {#if sets.length} 64 - <div class="flex flex-wrap gap-1"> 64 + <div class="flex flex-wrap items-center gap-1"> 65 65 {#each sets as set, i (i)} 66 66 <Badge>{set.forms.join('/')}</Badge> 67 67 {/each} 68 + {#if editing} 69 + <button 70 + type="button" 71 + class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 cursor-pointer p-1 transition-colors" 72 + onclick={openModal} 73 + > 74 + <svg 75 + xmlns="http://www.w3.org/2000/svg" 76 + fill="none" 77 + viewBox="0 0 24 24" 78 + stroke-width="1.5" 79 + stroke="currentColor" 80 + class="size-3.5" 81 + > 82 + <path 83 + stroke-linecap="round" 84 + stroke-linejoin="round" 85 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 86 + /> 87 + </svg> 88 + </button> 89 + {/if} 68 90 </div> 69 - {/if} 70 - 71 - {#if editing} 91 + {:else if editing} 72 92 <Button size="sm" variant="secondary" onclick={openModal} class="w-fit"> 73 93 <svg 74 94 xmlns="http://www.w3.org/2000/svg" ··· 84 104 d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 85 105 /> 86 106 </svg> 87 - {sets.length ? 'edit' : 'add'} pronouns 107 + add pronouns 88 108 </Button> 109 + {/if} 89 110 111 + {#if editing} 90 112 <Modal open={modalOpen} onOpenChange={(v) => (modalOpen = v)} closeButton> 91 113 <div class="flex flex-col gap-4"> 92 114 <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Edit pronouns</h3>
+234
src/lib/website/SectionsSidebar.svelte
··· 1 + <script lang="ts"> 2 + import type { SectionRecord, WebsiteData } from '$lib/types'; 3 + import { SectionDefinitionsByType, AllSectionDefinitions } from '$lib/sections'; 4 + import { getHideProfileSection } from '$lib/helper'; 5 + 6 + let { 7 + open = $bindable(false), 8 + sections = $bindable(), 9 + activeSectionId = $bindable(), 10 + data = $bindable(), 11 + ondelete, 12 + onlayoutchange, 13 + onadd 14 + }: { 15 + open: boolean; 16 + sections: SectionRecord[]; 17 + activeSectionId: string | undefined; 18 + data: WebsiteData; 19 + ondelete: (id: string) => void; 20 + onlayoutchange: () => void; 21 + onadd: (sectionType: string) => void; 22 + } = $props(); 23 + 24 + let hideProfile = $derived(getHideProfileSection(data)); 25 + 26 + function toggleProfile() { 27 + data.publication.preferences ??= {}; 28 + data.publication.preferences.hideProfileSection = !hideProfile; 29 + data = { ...data }; 30 + } 31 + 32 + function moveUp(index: number) { 33 + if (index <= 0) return; 34 + const sorted = sections.toSorted((a, b) => a.index - b.index); 35 + const prev = sorted[index - 1]; 36 + const curr = sorted[index]; 37 + const tmpIndex = prev.index; 38 + prev.index = curr.index; 39 + curr.index = tmpIndex; 40 + sections = [...sections]; 41 + onlayoutchange(); 42 + } 43 + 44 + function moveDown(index: number) { 45 + const sorted = sections.toSorted((a, b) => a.index - b.index); 46 + if (index >= sorted.length - 1) return; 47 + const next = sorted[index + 1]; 48 + const curr = sorted[index]; 49 + const tmpIndex = next.index; 50 + next.index = curr.index; 51 + curr.index = tmpIndex; 52 + sections = [...sections]; 53 + onlayoutchange(); 54 + } 55 + </script> 56 + 57 + <!-- Sidebar --> 58 + <div 59 + class="bg-base-100 dark:bg-base-950 border-base-200 dark:border-base-800 fixed top-0 left-0 z-20 flex h-full w-72 flex-col border-r shadow-lg transition-transform duration-200 {open 60 + ? 'translate-x-0' 61 + : '-translate-x-full'}" 62 + > 63 + <div 64 + class="border-base-200 dark:border-base-800 flex items-center justify-between border-b px-4 py-3" 65 + > 66 + <h2 class="text-base-900 dark:text-base-100 text-sm font-semibold">Layout</h2> 67 + <button 68 + type="button" 69 + class="text-base-500 hover:text-base-700 dark:text-base-400 dark:hover:text-base-200 cursor-pointer rounded-lg p-1" 70 + onclick={() => (open = false)} 71 + > 72 + <svg 73 + xmlns="http://www.w3.org/2000/svg" 74 + fill="none" 75 + viewBox="0 0 24 24" 76 + stroke-width="1.5" 77 + stroke="currentColor" 78 + class="size-4" 79 + > 80 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 81 + </svg> 82 + </button> 83 + </div> 84 + 85 + <div class="flex flex-1 flex-col gap-1 overflow-y-auto p-3"> 86 + <!-- Profile toggle --> 87 + <button 88 + type="button" 89 + class="hover:bg-base-100 dark:hover:bg-base-900 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 transition-colors" 90 + onclick={toggleProfile} 91 + > 92 + <span class="text-base-400 dark:text-base-500 shrink-0"> 93 + <svg 94 + xmlns="http://www.w3.org/2000/svg" 95 + viewBox="0 0 24 24" 96 + fill="none" 97 + stroke="currentColor" 98 + stroke-width="2" 99 + stroke-linecap="round" 100 + stroke-linejoin="round" 101 + class="size-4" 102 + > 103 + <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /> 104 + <circle cx="12" cy="7" r="4" /> 105 + </svg> 106 + </span> 107 + <span class="text-base-700 dark:text-base-300 flex-1 text-sm font-medium">Profile</span> 108 + <span 109 + class="text-xs {hideProfile 110 + ? 'text-base-400 dark:text-base-500' 111 + : 'text-accent-600 dark:text-accent-400'}" 112 + > 113 + {hideProfile ? 'Hidden' : 'Visible'} 114 + </span> 115 + </button> 116 + 117 + <div class="border-base-200 dark:border-base-800 my-1 border-t"></div> 118 + 119 + <!-- Sections --> 120 + <span class="text-base-400 dark:text-base-500 mb-0.5 px-2 text-xs font-medium">Sections</span> 121 + {#each sections.toSorted((a, b) => a.index - b.index) as section, i (section.id)} 122 + {@const def = SectionDefinitionsByType[section.sectionType]} 123 + <div 124 + class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors {activeSectionId === 125 + section.id 126 + ? 'bg-accent-50 dark:bg-accent-950/30' 127 + : 'hover:bg-base-100 dark:hover:bg-base-900'}" 128 + > 129 + <button 130 + type="button" 131 + class="flex flex-1 cursor-pointer items-center gap-2 text-left" 132 + onclick={() => { 133 + activeSectionId = section.id; 134 + }} 135 + > 136 + {#if def?.icon} 137 + <span class="text-base-400 dark:text-base-500 shrink-0 [&>svg]:size-4"> 138 + {@html def.icon} 139 + </span> 140 + {/if} 141 + <span 142 + class="text-sm font-medium {activeSectionId === section.id 143 + ? 'text-accent-700 dark:text-accent-300' 144 + : 'text-base-700 dark:text-base-300'}" 145 + > 146 + {section.name || def?.name || section.sectionType} 147 + </span> 148 + </button> 149 + 150 + <div class="flex items-center"> 151 + <button 152 + type="button" 153 + class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 cursor-pointer rounded p-0.5 transition-colors disabled:opacity-30" 154 + disabled={i === 0} 155 + onclick={() => moveUp(i)} 156 + > 157 + <svg 158 + xmlns="http://www.w3.org/2000/svg" 159 + viewBox="0 0 24 24" 160 + fill="none" 161 + stroke="currentColor" 162 + stroke-width="2" 163 + stroke-linecap="round" 164 + stroke-linejoin="round" 165 + class="size-3.5" 166 + > 167 + <path d="m18 15-6-6-6 6" /> 168 + </svg> 169 + </button> 170 + <button 171 + type="button" 172 + class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 cursor-pointer rounded p-0.5 transition-colors disabled:opacity-30" 173 + disabled={i === sections.length - 1} 174 + onclick={() => moveDown(i)} 175 + > 176 + <svg 177 + xmlns="http://www.w3.org/2000/svg" 178 + viewBox="0 0 24 24" 179 + fill="none" 180 + stroke="currentColor" 181 + stroke-width="2" 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + class="size-3.5" 185 + > 186 + <path d="m6 9 6 6 6-6" /> 187 + </svg> 188 + </button> 189 + {#if sections.length > 1} 190 + <button 191 + type="button" 192 + class="text-base-400 dark:text-base-500 cursor-pointer rounded p-0.5 transition-colors hover:text-rose-500" 193 + onclick={() => ondelete(section.id)} 194 + > 195 + <svg 196 + xmlns="http://www.w3.org/2000/svg" 197 + viewBox="0 0 24 24" 198 + fill="none" 199 + stroke="currentColor" 200 + stroke-width="2" 201 + stroke-linecap="round" 202 + stroke-linejoin="round" 203 + class="size-3.5" 204 + > 205 + <path d="M3 6h18" /> 206 + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> 207 + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> 208 + </svg> 209 + </button> 210 + {/if} 211 + </div> 212 + </div> 213 + {/each} 214 + </div> 215 + 216 + <!-- Add section --> 217 + <div class="border-base-200 dark:border-base-800 flex flex-col gap-1 border-t p-3"> 218 + <span class="text-base-400 dark:text-base-500 mb-1 px-2 text-xs font-medium">Add section</span> 219 + {#each AllSectionDefinitions as def (def.type)} 220 + <button 221 + type="button" 222 + class="text-base-600 dark:text-base-400 hover:bg-base-100 dark:hover:bg-base-900 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors" 223 + onclick={() => onadd(def.type)} 224 + > 225 + {#if def.icon} 226 + <span class="text-base-400 dark:text-base-500 shrink-0 [&>svg]:size-4"> 227 + {@html def.icon} 228 + </span> 229 + {/if} 230 + {def.name} 231 + </button> 232 + {/each} 233 + </div> 234 + </div>
+40 -38
src/lib/website/settings/SettingsOverlay.svelte
··· 1 1 <script lang="ts" module> 2 2 export const settingsOverlayState = $state({ 3 3 visible: false, 4 - activeSection: 'layout' as string, 4 + activeSection: 'page' as string, 5 5 show: () => (settingsOverlayState.visible = true), 6 6 hide: () => (settingsOverlayState.visible = false) 7 7 }); ··· 9 9 10 10 <script lang="ts"> 11 11 import type { WebsiteData } from '$lib/types'; 12 + import PageSection from './sections/PageSection.svelte'; 12 13 import LayoutSection from './sections/LayoutSection.svelte'; 13 14 import CustomDomainSection from './sections/CustomDomainSection.svelte'; 14 15 import AccountSection from './sections/AccountSection.svelte'; ··· 26 27 }); 27 28 28 29 const tabs = [ 30 + { id: 'page', label: 'Page' }, 29 31 { id: 'layout', label: 'Layout' }, 30 32 { id: 'domain', label: 'Custom Domain' }, 31 33 { id: 'account', label: 'Account' } ··· 35 37 {#if settingsOverlayState.visible} 36 38 <div class="bg-base-50 dark:bg-base-950 fixed inset-0 z-[100] flex flex-col overflow-hidden"> 37 39 <!-- 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"> 40 + <div class="border-base-200 dark:border-base-800 border-b px-6 pt-4"> 41 + <div class="flex items-center justify-between"> 42 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" 43 + <!-- Close button --> 44 + <button 45 + type="button" 46 + class="text-base-500 hover:text-base-700 dark:text-base-400 dark:hover:text-base-200 cursor-pointer rounded-lg p-1.5" 47 + onclick={() => settingsOverlayState.hide()} 71 48 > 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> 49 + <svg 50 + xmlns="http://www.w3.org/2000/svg" 51 + fill="none" 52 + viewBox="0 0 24 24" 53 + stroke-width="1.5" 54 + stroke="currentColor" 55 + class="size-5" 56 + > 57 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 58 + </svg> 59 + <span class="sr-only">Close settings</span> 60 + </button> 61 + </div> 62 + <nav class="-mb-px flex gap-1 overflow-x-auto pt-3"> 63 + {#each tabs as tab (tab.id)} 64 + <button 65 + type="button" 66 + class="cursor-pointer rounded-t-lg px-3 py-1.5 text-sm font-medium whitespace-nowrap {settingsOverlayState.activeSection === 67 + tab.id 68 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-100' 69 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-300'}" 70 + onclick={() => (settingsOverlayState.activeSection = tab.id)} 71 + > 72 + {tab.label} 73 + </button> 74 + {/each} 75 + </nav> 76 76 </div> 77 77 78 78 <!-- Content area --> 79 79 <div class="flex-1 overflow-y-auto px-6 py-8"> 80 80 <div class="mx-auto max-w-xl"> 81 - {#if settingsOverlayState.activeSection === 'layout'} 81 + {#if settingsOverlayState.activeSection === 'page'} 82 + <PageSection bind:data /> 83 + {:else if settingsOverlayState.activeSection === 'layout'} 82 84 <LayoutSection bind:data /> 83 85 {:else if settingsOverlayState.activeSection === 'domain'} 84 86 <CustomDomainSection {publicationUrl} />
+99
src/lib/website/settings/sections/PageSection.svelte
··· 1 + <script lang="ts"> 2 + import type { WebsiteData } from '$lib/types'; 3 + import { getHideProfileSection, getProfilePosition } from '$lib/helper'; 4 + import SelectTheme from '$lib/components/select-theme/SelectTheme.svelte'; 5 + import { Checkbox, Label } from '@foxui/core'; 6 + 7 + let { data = $bindable() }: { data: WebsiteData } = $props(); 8 + 9 + let hideProfile = $derived(getHideProfileSection(data)); 10 + let profilePosition = $derived(getProfilePosition(data)); 11 + 12 + function setProfilePosition(position: 'top' | 'side') { 13 + data.publication.preferences ??= {}; 14 + data.publication.preferences.profilePosition = position; 15 + data = { ...data }; 16 + } 17 + 18 + const positionOptions = [ 19 + { value: 'top' as const, label: 'Top', description: 'Profile appears at the top of the page' }, 20 + { 21 + value: 'side' as const, 22 + label: 'Side', 23 + description: 'Profile appears on the left side (desktop only)' 24 + } 25 + ]; 26 + </script> 27 + 28 + <!-- Profile Section --> 29 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Profile</h3> 30 + <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 31 + Show or hide the profile section on your page. 32 + </p> 33 + 34 + <div class="mt-4 flex items-center gap-2"> 35 + <Checkbox 36 + id="show-profile" 37 + checked={!hideProfile} 38 + onCheckedChange={(checked) => { 39 + data.publication.preferences ??= {}; 40 + data.publication.preferences.hideProfileSection = !checked; 41 + data = { ...data }; 42 + }} 43 + /> 44 + <Label for="show-profile" class="text-sm leading-none font-medium">Show profile</Label> 45 + </div> 46 + 47 + {#if !hideProfile} 48 + <div class="mt-6"> 49 + <h4 class="text-base-800 dark:text-base-200 text-sm font-semibold">Position</h4> 50 + <div class="mt-3 flex flex-col gap-2"> 51 + {#each positionOptions as option (option.value)} 52 + {@const isSelected = profilePosition === option.value} 53 + <button 54 + type="button" 55 + 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 56 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950/20' 57 + : 'bg-base-50 dark:bg-base-800/50'}" 58 + onclick={() => setProfilePosition(option.value)} 59 + > 60 + <span 61 + class="text-sm font-semibold {isSelected 62 + ? 'text-accent-700 dark:text-accent-300' 63 + : 'text-base-900 dark:text-base-100'}" 64 + > 65 + {option.label} 66 + </span> 67 + <p 68 + class="mt-0.5 text-xs {isSelected 69 + ? 'text-accent-600 dark:text-accent-400' 70 + : 'text-base-500 dark:text-base-400'}" 71 + > 72 + {option.description} 73 + </p> 74 + </button> 75 + {/each} 76 + </div> 77 + </div> 78 + {/if} 79 + 80 + <hr class="border-base-200 dark:border-base-700 my-8" /> 81 + 82 + <!-- Colors Section --> 83 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Colors</h3> 84 + <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 85 + Choose the accent and base colors for your site. 86 + </p> 87 + 88 + <div class="mt-4"> 89 + <SelectTheme 90 + accentColor={data.publication.preferences?.accentColor ?? 'pink'} 91 + baseColor={data.publication.preferences?.baseColor ?? 'stone'} 92 + onchanged={(accentColor, baseColor) => { 93 + data.publication.preferences ??= {}; 94 + data.publication.preferences.accentColor = accentColor; 95 + data.publication.preferences.baseColor = baseColor; 96 + data = { ...data }; 97 + }} 98 + /> 99 + </div>
+11 -4
src/routes/(legal)/imprint/+page.svelte
··· 4 4 </svelte:head> 5 5 6 6 <h1>Imprint</h1> 7 - <p><em>Last updated: April 18, 2026</em></p> 7 + <p><em>Last updated: April 19, 2026</em></p> 8 8 9 9 <p> 10 10 Information in accordance with &sect; 5 TMG (German Telemedia Act) and &sect; 18 MStV (German ··· 91 91 92 92 <h3>Copyright</h3> 93 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. 94 + Content published on this site by the operator (texts, images, and other media) is subject to 95 + copyright law. Reproduction, processing, distribution, or any form of commercialisation of such 96 + material beyond the scope of copyright law requires the prior written consent of its respective 97 + author or creator. 98 + </p> 99 + <p> 100 + The source code of blento is licensed separately under the MIT License and is available at 101 + <a href="https://github.com/flo-bit/blento" target="_blank" rel="noopener" 102 + >github.com/flo-bit/blento</a 103 + >. 97 104 </p>
+61 -9
src/routes/(legal)/terms/+page.svelte
··· 4 4 </svelte:head> 5 5 6 6 <h1>Terms of Service</h1> 7 - <p><em>Last updated: April 18, 2026</em></p> 7 + <p><em>Last updated: April 19, 2026</em></p> 8 8 9 9 <h2>1. Scope and Acceptance</h2> 10 10 <p> ··· 98 98 appropriate. Where feasible, we will provide a statement of reasons for any action taken. 99 99 </p> 100 100 101 - <h2>8. Disclaimer</h2> 101 + <h2>8. Copyright Complaints and Counter-Notices</h2> 102 + <p> 103 + If you believe that content rendered via blento infringes your copyright, you can notify us by 104 + emailing <a href="mailto:hello@blento.app">hello@blento.app</a>. Please include: 105 + </p> 106 + <ul> 107 + <li>a description of the copyrighted work you believe has been infringed;</li> 108 + <li>the exact URL(s) of the allegedly infringing content on blento;</li> 109 + <li>your name, postal address, email, and where available a phone number;</li> 110 + <li> 111 + a good-faith statement that the use of the material is not authorised by the copyright owner, 112 + its agent, or the law; 113 + </li> 114 + <li> 115 + a statement that the information in the notice is accurate and that you are the copyright owner 116 + or authorised to act on their behalf; 117 + </li> 118 + <li>your physical or electronic signature.</li> 119 + </ul> 120 + <p> 121 + <strong>How removal works on blento.</strong> Because content you create on blento is stored on the 122 + user&rsquo;s own PDS on the AT Protocol network and not hosted by us, our remedy is to stop rendering 123 + the identified content or site via blento. The underlying record remains under the user&rsquo;s control 124 + on atproto and may continue to be available through other applications. 125 + </p> 126 + <h3>Counter-notice</h3> 127 + <p> 128 + If your content was removed or access to it was disabled and you believe this was in error or 129 + misidentification, you may send a counter-notice to 130 + <a href="mailto:hello@blento.app">hello@blento.app</a> containing: 131 + </p> 132 + <ul> 133 + <li>your contact information (name, address, email);</li> 134 + <li>identification of the material removed and the URL(s) at which it appeared;</li> 135 + <li> 136 + a statement, made in good faith, that the material was removed as a result of mistake or 137 + misidentification; 138 + </li> 139 + <li>your physical or electronic signature.</li> 140 + </ul> 141 + <p> 142 + We will review counter-notices in good faith and respond by email within a reasonable timeframe. 143 + This process operates alongside the internal complaint-handling rights available to you under 144 + Article 20 of the EU Digital Services Act. 145 + </p> 146 + <h3>Repeat infringers</h3> 147 + <p> 148 + We may, in appropriate circumstances and at our discretion, refuse service to atmosphere accounts 149 + (DIDs or handles) that are repeatedly the subject of substantiated infringement notices, as well 150 + as to parties who repeatedly submit unfounded, inaccurate, or abusive notices. 151 + </p> 152 + 153 + <h2>9. Disclaimer</h2> 102 154 <p> 103 155 The Service is provided as is and as available. We do not warrant that the Service will be 104 156 uninterrupted, error-free, or that cached third-party content will be current or accurate. 105 157 Statutory warranty rights under German law remain unaffected. 106 158 </p> 107 159 108 - <h2>9. Liability</h2> 160 + <h2>10. Liability</h2> 109 161 <p> 110 162 We are liable without limitation for damages caused by intent or gross negligence, for injury to 111 163 life, body, or health, under the German Product Liability Act (ProdHaftG), to the extent of any ··· 125 177 law. 126 178 </p> 127 179 128 - <h2>10. Termination</h2> 180 + <h2>11. Termination</h2> 129 181 <p> 130 182 You may stop using the Service at any time. On request to 131 183 <a href="mailto:hello@blento.app">hello@blento.app</a> we will delete any data we hold about your account ··· 133 185 these Terms, with notice where reasonably possible. 134 186 </p> 135 187 136 - <h2>11. Changes to These Terms</h2> 188 + <h2>12. Changes to These Terms</h2> 137 189 <p> 138 190 We may update these Terms where necessary, for example to reflect legal changes or new features. 139 191 We will notify you of material changes at least 30 days before they take effect, by posting the ··· 143 195 of the Service during this period if you do not agree. 144 196 </p> 145 197 146 - <h2>12. Governing Law and Venue</h2> 198 + <h2>13. Governing Law and Venue</h2> 147 199 <p> 148 200 These Terms are governed by the laws of the Federal Republic of Germany, excluding the UN 149 201 Convention on Contracts for the International Sale of Goods. If you are a consumer with habitual ··· 157 209 statutory places of jurisdiction apply. 158 210 </p> 159 211 160 - <h2>13. Consumer Dispute Resolution</h2> 212 + <h2>14. Consumer Dispute Resolution</h2> 161 213 <p> 162 214 The European Commission provides an online dispute resolution platform (OS): 163 215 <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener" ··· 166 218 consumer arbitration board (&sect; 36 VSBG). 167 219 </p> 168 220 169 - <h2>14. Severability</h2> 221 + <h2>15. Severability</h2> 170 222 <p> 171 223 Should any provision of these Terms be or become invalid or unenforceable, the validity of the 172 224 remaining provisions shall not be affected. 173 225 </p> 174 226 175 - <h2>15. Contact</h2> 227 + <h2>16. Contact</h2> 176 228 <p>Questions about these Terms? Reach out via:</p> 177 229 <ul> 178 230 <li>Email: <a href="mailto:hello@blento.app">hello@blento.app</a></li>