From 8608a8f4f9da6190e4a802494b79af5903a71460 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:39:40 +0200 Subject: [PATCH] sections --- lex.config.js | 1 + lexicons/app/blento/card.json | 1 + lexicons/app/blento/section.json | 24 + src/lib/atproto/settings.ts | 1 + src/lib/contrail/config.ts | 6 + src/lib/helper.ts | 33 +- .../GridSection/EditingGridSection.svelte | 150 +++++ .../sections/GridSection/GridSection.svelte | 24 + src/lib/sections/GridSection/add-item.ts | 45 ++ src/lib/sections/GridSection/index.ts | 37 ++ src/lib/sections/index.ts | 12 + src/lib/sections/migrate.ts | 27 + src/lib/sections/types.ts | 36 ++ src/lib/types.ts | 15 + src/lib/website/AddSectionButton.svelte | 59 ++ src/lib/website/EditBar.svelte | 43 +- src/lib/website/EditableWebsite.svelte | 514 ++++++------------ src/lib/website/SectionsModal.svelte | 140 +++++ src/lib/website/Website.svelte | 31 +- src/lib/website/file-processing.ts | 79 +++ src/lib/website/load.ts | 45 +- 21 files changed, 920 insertions(+), 403 deletions(-) create mode 100644 lexicons/app/blento/section.json create mode 100644 src/lib/sections/GridSection/EditingGridSection.svelte create mode 100644 src/lib/sections/GridSection/GridSection.svelte create mode 100644 src/lib/sections/GridSection/add-item.ts create mode 100644 src/lib/sections/GridSection/index.ts create mode 100644 src/lib/sections/index.ts create mode 100644 src/lib/sections/migrate.ts create mode 100644 src/lib/sections/types.ts create mode 100644 src/lib/website/AddSectionButton.svelte create mode 100644 src/lib/website/SectionsModal.svelte create mode 100644 src/lib/website/file-processing.ts diff --git a/lex.config.js b/lex.config.js index 44743a3..7c88fce 100644 --- a/lex.config.js +++ b/lex.config.js @@ -13,6 +13,7 @@ export default defineLexiconConfig({ nsids: [ 'app.blento.card', 'app.blento.page', + 'app.blento.section', 'app.bsky.actor.profile', 'site.standard.publication' ] diff --git a/lexicons/app/blento/card.json b/lexicons/app/blento/card.json index bcee074..3a11945 100644 --- a/lexicons/app/blento/card.json +++ b/lexicons/app/blento/card.json @@ -22,6 +22,7 @@ "cardData": { "type": "unknown" }, "color": { "type": "string" }, "page": { "type": "string" }, + "sectionId": { "type": "string" }, "updatedAt": { "type": "string", "format": "datetime" }, "version": { "type": "integer" } } diff --git a/lexicons/app/blento/section.json b/lexicons/app/blento/section.json new file mode 100644 index 0000000..cae1486 --- /dev/null +++ b/lexicons/app/blento/section.json @@ -0,0 +1,24 @@ +{ + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "id": "app.blento.section", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["sectionType", "page", "index"], + "properties": { + "sectionType": { "type": "string" }, + "page": { "type": "string" }, + "index": { "type": "integer" }, + "sectionData": { "type": "unknown" }, + "name": { "type": "string" }, + "updatedAt": { "type": "string", "format": "datetime" }, + "version": { "type": "integer" } + } + } + } + } +} diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 63cf291..5c10bfd 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -6,6 +6,7 @@ export const SITE = env.PUBLIC_DOMAIN; export const collections = [ 'app.blento.card', 'app.blento.page', + 'app.blento.section', 'app.blento.settings', 'app.blento.comment', 'app.blento.guestbook.entry', diff --git a/src/lib/contrail/config.ts b/src/lib/contrail/config.ts index ab90354..60034a5 100644 --- a/src/lib/contrail/config.ts +++ b/src/lib/contrail/config.ts @@ -11,6 +11,12 @@ export const config: ContrailConfig = { }, 'app.blento.page': { queryable: {} + }, + 'app.blento.section': { + queryable: { + page: {}, + sectionType: {} + } } }, profiles: [ diff --git a/src/lib/helper.ts b/src/lib/helper.ts index 1c078b6..8e35f7c 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -188,10 +188,36 @@ export async function savePage( data: WebsiteData, currentItems: Item[], originalCards: Item[], - originalPublication: string + originalPublication: string, + originalSections?: import('$lib/types').SectionRecord[] ) { const promises = []; + // Save sections + for (const section of data.sections) { + section.updatedAt = new Date().toISOString(); + section.version = 1; + const record = JSON.parse(JSON.stringify(section)); + const rkey = record.id; + delete record.id; + promises.push( + putRecord({ + collection: 'app.blento.section', + rkey, + record + }) + ); + } + + // Delete removed sections + if (originalSections) { + for (const original of originalSections) { + if (!data.sections.find((s) => s.id === original.id)) { + promises.push(deleteRecord({ collection: 'app.blento.section', rkey: original.id })); + } + } + } + // Save all current cards. We don't diff against originals because the // server-side load can modify cards (e.g. fixing overlaps), so the // "original" the client sees is already the post-fix version — there's @@ -301,7 +327,7 @@ export async function savePage( fetch(`/${data.did}/og-new.png`, { method: 'DELETE' }).catch(() => {}); } -export function createEmptyCard(page: string) { +export function createEmptyCard(page: string, sectionId?: string) { return { id: TID.now(), x: 0, @@ -314,7 +340,8 @@ export function createEmptyCard(page: string) { mobileY: 0, cardType: '', cardData: {}, - page + page, + sectionId } as Item; } diff --git a/src/lib/sections/GridSection/EditingGridSection.svelte b/src/lib/sections/GridSection/EditingGridSection.svelte new file mode 100644 index 0000000..d8c95fa --- /dev/null +++ b/src/lib/sections/GridSection/EditingGridSection.svelte @@ -0,0 +1,150 @@ + + + + {#if hovered || isActive} +
+
+ {#if def?.icon} + {@html def.icon} + {/if} + {section.name || def?.name || section.sectionType} +
+
+ {/if} + + {#if sectionItems.length === 0} +
+

+ Click the + button to add cards to this section +

+
+ {/if} + + {#each sectionItems as item (item.id)} + {@const idx = items.indexOf(item)} + { + items = items.filter((it) => it !== item); + compactItems( + items.filter((i) => i.sectionId === section.id), + false + ); + compactItems( + items.filter((i) => i.sectionId === section.id), + true + ); + onlayoutchange(); + }} + onsetsize={(newW, newH) => { + if (isMobile) { + item.mobileW = newW; + item.mobileH = newH; + } else { + item.w = newW; + item.h = newH; + } + fixCollisions( + items.filter((i) => i.sectionId === section.id), + item, + isMobile + ); + onlayoutchange(); + }} + > + + + {/each} +
diff --git a/src/lib/sections/GridSection/GridSection.svelte b/src/lib/sections/GridSection/GridSection.svelte new file mode 100644 index 0000000..632fb80 --- /dev/null +++ b/src/lib/sections/GridSection/GridSection.svelte @@ -0,0 +1,24 @@ + + +
+ {#each items.toSorted(sortItems) as item (item.id)} + + + + {/each} +
+
diff --git a/src/lib/sections/GridSection/add-item.ts b/src/lib/sections/GridSection/add-item.ts new file mode 100644 index 0000000..4a79cd0 --- /dev/null +++ b/src/lib/sections/GridSection/add-item.ts @@ -0,0 +1,45 @@ +import { COLUMNS } from '$lib'; +import { + setPositionOfNewItem, + fixCollisions, + compactItems, + getViewportCenterGridY +} from '$lib/layout'; +import type { Item } from '$lib/types'; +import type { AddItemOptions } from '../types'; + +export function addItemToGridSection( + item: Item, + allItems: Item[], + options: AddItemOptions +): Item[] { + const sectionItems = allItems.filter((i) => i.sectionId === item.sectionId); + const viewportCenter = options.gridRef + ? getViewportCenterGridY(options.gridRef, options.isMobile) + : undefined; + setPositionOfNewItem(item, sectionItems, viewportCenter); + + const newItems = [...allItems, item]; + const updatedSectionItems = newItems.filter((i) => i.sectionId === item.sectionId); + + fixCollisions(updatedSectionItems, item, false, true); + fixCollisions(updatedSectionItems, item, true, true); + compactItems(updatedSectionItems, false); + compactItems(updatedSectionItems, true); + + return newItems; +} + +export function positionItemAtGridPos(item: Item, gridX: number, gridY: number, isMobile: boolean) { + if (isMobile) { + item.mobileX = gridX; + item.mobileY = gridY; + item.x = Math.floor((COLUMNS - item.w) / 2); + item.y = Math.max(0, Math.round(gridY / 2)); + } else { + item.x = gridX; + item.y = gridY; + item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); + item.mobileY = Math.max(0, Math.round(gridY * 2)); + } +} diff --git a/src/lib/sections/GridSection/index.ts b/src/lib/sections/GridSection/index.ts new file mode 100644 index 0000000..734e8f0 --- /dev/null +++ b/src/lib/sections/GridSection/index.ts @@ -0,0 +1,37 @@ +import { fixCollisions, compactItems } from '$lib/layout'; +import type { Item } from '$lib/types'; +import type { SectionDefinition } from '../types'; +import { addItemToGridSection } from './add-item'; +import EditingGridSection from './EditingGridSection.svelte'; +import GridSection from './GridSection.svelte'; + +function getSectionItems(allItems: Item[], sectionId: string) { + return allItems.filter((i) => i.sectionId === sectionId); +} + +export const GridSectionDefinition: SectionDefinition = { + type: 'grid', + contentComponent: GridSection, + editingContentComponent: EditingGridSection, + addItem: (item, allItems, options) => addItemToGridSection(item, allItems, options), + deleteItem: (itemId, allItems, sectionId) => { + const newItems = allItems.filter((i) => i.id !== itemId); + const sectionItems = getSectionItems(newItems, sectionId); + compactItems(sectionItems, false); + compactItems(sectionItems, true); + return newItems; + }, + resizeItem: (item, allItems, w, h, isMobile) => { + if (isMobile) { + item.mobileW = w; + item.mobileH = h; + } else { + item.w = w; + item.h = h; + } + const sectionItems = getSectionItems(allItems, item.sectionId!); + fixCollisions(sectionItems, item, isMobile); + }, + name: 'Grid', + icon: `` +}; diff --git a/src/lib/sections/index.ts b/src/lib/sections/index.ts new file mode 100644 index 0000000..0821e19 --- /dev/null +++ b/src/lib/sections/index.ts @@ -0,0 +1,12 @@ +import type { SectionDefinition } from './types'; +import { GridSectionDefinition } from './GridSection'; + +export const AllSectionDefinitions = [GridSectionDefinition] as const; + +export const SectionDefinitionsByType = AllSectionDefinitions.reduce( + (acc, def) => { + acc[def.type] = def; + return acc; + }, + {} as Record +); diff --git a/src/lib/sections/migrate.ts b/src/lib/sections/migrate.ts new file mode 100644 index 0000000..e0a8ff0 --- /dev/null +++ b/src/lib/sections/migrate.ts @@ -0,0 +1,27 @@ +import * as TID from '@atcute/tid'; +import type { Item, SectionRecord } from '$lib/types'; + +export function ensureSections( + storedSections: SectionRecord[], + cards: Item[], + page: string +): { sections: SectionRecord[]; cards: Item[] } { + if (storedSections.length > 0) { + const firstGridId = + storedSections.find((s) => s.sectionType === 'grid')?.id ?? storedSections[0].id; + const fixedCards = cards.map((c) => (c.sectionId ? c : { ...c, sectionId: firstGridId })); + return { sections: storedSections, cards: fixedCards }; + } + + const newId = TID.now(); + const synthesized: SectionRecord = { + id: newId, + sectionType: 'grid', + page, + index: 0, + sectionData: {}, + version: 1 + }; + const fixedCards = cards.map((c) => ({ ...c, sectionId: c.sectionId ?? newId })); + return { sections: [synthesized], cards: fixedCards }; +} diff --git a/src/lib/sections/types.ts b/src/lib/sections/types.ts new file mode 100644 index 0000000..c4ee5bf --- /dev/null +++ b/src/lib/sections/types.ts @@ -0,0 +1,36 @@ +import type { Component } from 'svelte'; +import type { Item, SectionRecord } from '$lib/types'; + +export type SectionContentProps = { + section: SectionRecord; + items: Item[]; + isMobile: boolean; +}; + +export type EditingSectionContentProps = SectionContentProps & { + selectedCardId: string | null; + isCoarse: boolean; + isActive: boolean; + onlayoutchange: () => void; + ondeselect: () => void; + onadditem: (item: Item) => void; + oncreatefilecards: (files: File[]) => Promise; + onactivate: () => void; + onrefchange: (el: HTMLDivElement | undefined) => void; +}; + +export type AddItemOptions = { + gridRef?: HTMLDivElement; + isMobile: boolean; +}; + +export type SectionDefinition = { + type: string; + contentComponent: Component; + editingContentComponent: Component; + addItem: (item: Item, allItems: Item[], options: AddItemOptions) => Item[]; + deleteItem: (itemId: string, allItems: Item[], sectionId: string) => Item[]; + resizeItem: (item: Item, allItems: Item[], w: number, h: number, isMobile: boolean) => void; + name: string; + icon?: string; +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index d6728f6..d5fbd41 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,6 +25,8 @@ export type Item = { version?: number; page?: string; + + sectionId?: string; }; export type PronounSet = { @@ -40,6 +42,17 @@ export type PronounsRecord = { }; }; +export type SectionRecord = { + id: string; + sectionType: string; + page: string; + index: number; + sectionData: Record; + name?: string; + updatedAt?: string; + version?: number; +}; + export type WebsiteData = { page: string; did: string; @@ -80,6 +93,8 @@ export type WebsiteData = { pronouns?: string; pronounsRecord?: PronounsRecord; + sections: SectionRecord[]; + additionalData: Record; updatedAt: number; version?: number; diff --git a/src/lib/website/AddSectionButton.svelte b/src/lib/website/AddSectionButton.svelte new file mode 100644 index 0000000..dfeca57 --- /dev/null +++ b/src/lib/website/AddSectionButton.svelte @@ -0,0 +1,59 @@ + + +
+ + {#snippet child({ props })} + + {/snippet} +
+ {#each AllSectionDefinitions as def (def.type)} + + {/each} +
+
+
diff --git a/src/lib/website/EditBar.svelte b/src/lib/website/EditBar.svelte index 293a085..d7161ed 100644 --- a/src/lib/website/EditBar.svelte +++ b/src/lib/website/EditBar.svelte @@ -17,8 +17,7 @@ save, - handleImageInputChange, - handleVideoInputChange, + handleFileInputChange, newCard, addLink, @@ -30,7 +29,8 @@ isCoarse = false, ondeselect, ondelete, - onsetsize + onsetsize, + showSectionsModal }: { data: WebsiteData; @@ -41,8 +41,7 @@ save: () => Promise; - handleImageInputChange: (evt: Event) => void; - handleVideoInputChange: (evt: Event) => void; + handleFileInputChange: (evt: Event) => void; newCard: (type?: string, cardData?: any) => void; addLink: (url: string) => void; @@ -55,11 +54,11 @@ ondeselect?: () => void; ondelete?: () => void; onsetsize?: (w: number, h: number) => void; + showSectionsModal: () => void; } = $props(); let linkPopoverOpen = $state(false); let imageInputRef: HTMLInputElement | undefined = $state(); - let videoInputRef: HTMLInputElement | undefined = $state(); function getShareUrl() { const base = typeof window !== 'undefined' ? window.location.origin : ''; @@ -136,24 +135,14 @@ - - {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} {/if}
+