diff --git a/package.json b/package.json index 720b3e6..f1e281d 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,12 @@ "@fuxui/social": "^0.0.9", "@tailwindcss/typography": "^0.5.16", "@tiptap/core": "^2.12.0", + "@tiptap/extension-document": "^2.12.0", "@tiptap/extension-image": "^2.12.0", "@tiptap/extension-link": "^2.12.0", + "@tiptap/extension-paragraph": "^2.12.0", "@tiptap/extension-placeholder": "^2.12.0", + "@tiptap/extension-text": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", "marked": "^15.0.11", "turndown": "^7.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 539a3fc..f07db8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,15 +26,24 @@ importers: '@tiptap/core': specifier: ^2.12.0 version: 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/extension-document': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) '@tiptap/extension-image': specifier: ^2.12.0 version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) '@tiptap/extension-link': specifier: ^2.12.0 version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) + '@tiptap/extension-paragraph': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) '@tiptap/extension-placeholder': specifier: ^2.12.0 version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) + '@tiptap/extension-text': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) '@tiptap/starter-kit': specifier: ^2.12.0 version: 2.12.0 diff --git a/src/lib/oauth/atproto.ts b/src/lib/oauth/atproto.ts index cb3e499..717563d 100644 --- a/src/lib/oauth/atproto.ts +++ b/src/lib/oauth/atproto.ts @@ -90,6 +90,16 @@ export async function putRecord({ }) { if (!client.profile || !client.rpc) throw new Error('No profile or rpc'); + console.log('updating record', { + data: { + collection, + repo: client.profile.did, + rkey, + record: { + ...record + } + } + }); const response = await client.rpc.call('com.atproto.repo.putRecord', { data: { collection, @@ -103,3 +113,25 @@ export async function putRecord({ return response; } + +export async function deleteRecord({ + did, + collection, + rkey +}: { + did: string; + collection: string; + rkey: string; +}) { + if (!client.profile || !client.rpc) throw new Error('No profile or rpc'); + + const response = await client.rpc.call('com.atproto.repo.deleteRecord', { + data: { + collection, + repo: did, + rkey + } + }); + + return response; +} diff --git a/src/lib/website/EditingList.svelte b/src/lib/website/EditingList.svelte new file mode 100644 index 0000000..84d4989 --- /dev/null +++ b/src/lib/website/EditingList.svelte @@ -0,0 +1,55 @@ + + +{#if allRecords.length === 0 && empty} + {@render empty()} +{:else} + {#each allRecords as itemData (itemData.uri)} + + {#snippet child(data)} + {@render item(data, async () => { + const { rkey } = parseUri(itemData.uri as string); + await deleteRecord({ did, collection, rkey }); + + allRecords = allRecords.filter((record) => record.uri !== itemData.uri); + })} + {/snippet} + + {/each} +{/if} + +{@render addItem?.(() => { + console.log('clicked'); + const rkey = TID.nextStr(); + + allRecords.push({ + uri: 'at://' + did + '/' + collection + '/' + rkey, + value: {} + }); +})} diff --git a/src/lib/website/Link.svelte b/src/lib/website/Link.svelte new file mode 100644 index 0000000..78a885b --- /dev/null +++ b/src/lib/website/Link.svelte @@ -0,0 +1,25 @@ + + +
+
+ + </div> + <div class="z-10 pointer-events-none"> + <PlainText key="description" {data} placeholder="Write a description..." /> + </div> + + {#if hasContext('isEditing')} + <button class="absolute top-0 right-0" onclick={deleteItem}>Delete</button> + {:else} + <a + href={data.url} + target="_blank" + class="hover:bg-base-200/50 absolute -inset-2 rounded-2xl" + ></a> + {/if} +</div> diff --git a/src/lib/website/List.svelte b/src/lib/website/List.svelte index 8a1de1b..98da7f2 100644 --- a/src/lib/website/List.svelte +++ b/src/lib/website/List.svelte @@ -2,31 +2,36 @@ import { getContext, hasContext, type Snippet } from 'svelte'; import type { Collection } from './data'; import { listRecords } from '$lib/oauth/atproto'; + import EditingList from './EditingList.svelte'; let { collection, - data, - item + item, + addItem, + empty }: { collection: Collection; - data: Record<string, unknown>; - item: Snippet<[any]>; + item: Snippet<[any, any]>; + addItem: Snippet<[any]>; + empty?: Snippet; } = $props(); + let data = getContext('data') as Record<string, unknown>; const did = getContext('did') as string; </script> {#if hasContext('isEditing')} {#await listRecords({ did, collection }) then records} - {#each records as itemData} - {@render item(itemData)} - {/each} + <EditingList {records} {collection} {item} {addItem} {empty} /> {:catch error} {error} {/await} {:else} -{#each data[collection] as itemData} - {@render item(itemData)} -{/each} + {#each data[collection] as itemData} + {@render item(itemData.value, () => {})} + {/each} -{/if} \ No newline at end of file + {#if (!data[collection] || (data[collection] as any[]).length === 0) && empty} + {@render empty()} + {/if} +{/if} diff --git a/src/lib/website/PlainText.svelte b/src/lib/website/PlainText.svelte new file mode 100644 index 0000000..71e6e64 --- /dev/null +++ b/src/lib/website/PlainText.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { getContext } from 'svelte'; + + let { + key, + data, + placeholder, + defaultContent + }: { + key: string; + data: Record<string, any>; + placeholder?: string; + defaultContent?: string; + } = $props(); +</script> + +{#if getContext('isEditing')} + {#await import('./PlainTextEditor.svelte') then { default: PlainTextEditor }} + <PlainTextEditor {key} {data} {placeholder} {defaultContent} /> + {/await} +{:else} + <div>{data[key] || defaultContent || placeholder}</div> +{/if} diff --git a/src/lib/website/PlainTextEditor.svelte b/src/lib/website/PlainTextEditor.svelte new file mode 100644 index 0000000..a0067de --- /dev/null +++ b/src/lib/website/PlainTextEditor.svelte @@ -0,0 +1,108 @@ +<script lang="ts"> + import { getContext, onDestroy, onMount } from 'svelte'; + import { Editor, type Extensions } from '@tiptap/core'; + import StarterKit from '@tiptap/starter-kit'; + import type { UpdateRecordFunction } from './context'; + import Placeholder from '@tiptap/extension-placeholder'; + import Paragraph from '@tiptap/extension-paragraph' + import Document from '@tiptap/extension-document' + import Text from '@tiptap/extension-text' + + let element: HTMLElement | undefined = $state(); + let editor: Editor | null = $state(null); + + let edited = $state(false); + + const updateFunctions = getContext('updateRecord') as UpdateRecordFunction[]; + + let updated = $state(false); + + let { + key, + data, + class: className, + placeholder = '', + defaultContent = '' + }: { + key: string; + data: Record<string, any>; + class?: string; + placeholder?: string; + defaultContent?: string; + } = $props(); + + const update = async () => { + if(updated) { + updated = false; + console.log('update', key, editor?.getText()); + return { + [key]: editor?.getText() ?? '' + } + } + return {}; + }; + + onMount(async () => { + if (!element || editor) return; + + updateFunctions.push(update); + + let extensions: Extensions = [ + Document.configure(), + Paragraph.configure(), + Text.configure(), + ]; + + if(placeholder) { + extensions.push( + Placeholder.configure({ + placeholder: placeholder + }) + ); + } + + editor = new Editor({ + element: element, + extensions: extensions, + onTransaction: () => { + editor = editor; + }, + onUpdate: () => { + edited = true; + + updated = true; + }, + + content: data[key] ?? defaultContent, + + editorProps: { + attributes: { + class: 'outline-none pointer-events-auto' + } + } + }); + }); + + onDestroy(() => { + if (editor) { + editor.destroy(); + } + + updateFunctions.splice(updateFunctions.indexOf(update), 1); + }); +</script> + +<div class={className} bind:this={element}></div> + +<style> + :global(.tiptap p.is-editor-empty:first-child::before) { + color: var(--color-base-800); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + } + :global(.dark .tiptap p.is-editor-empty:first-child::before) { + color: var(--color-base-200); + } +</style> diff --git a/src/lib/website/PlainTextItem.svelte b/src/lib/website/PlainTextItem.svelte new file mode 100644 index 0000000..a9ada9d --- /dev/null +++ b/src/lib/website/PlainTextItem.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import { getRecord } from '$lib/oauth/atproto'; + import { getContext } from 'svelte'; + import SingleRecord from './SingleRecord.svelte'; + + let { + collection, + rkey, + key, + data, + placeholder, + defaultContent + }: { + collection: string; + rkey: string; + key: string; + data: Record<string, any>; + placeholder?: string; + defaultContent?: string; + } = $props(); + + let did = getContext('did') as string; +</script> + +{#if getContext('isEditing')} + {#await getRecord({ did, collection, rkey }) then record} + <SingleRecord data={record}> + {#snippet child(recordData)} + {#await import('./PlainTextEditor.svelte') then { default: PlainTextEditor }} + <PlainTextEditor {key} data={recordData} {placeholder} {defaultContent} /> + {/await} + {/snippet} + </SingleRecord> + {:catch error} + <SingleRecord data={{ + uri: 'at://' + did + '/' + collection + '/' + rkey, + value: {} + }}> + {#snippet child(recordData)} + {#await import('./PlainTextEditor.svelte') then { default: PlainTextEditor }} + <PlainTextEditor {key} data={recordData} {placeholder} {defaultContent} /> + {/await} + {/snippet} + </SingleRecord> + {/await} +{:else} + <div>{data?.[collection]?.[rkey]?.value?.[key] || defaultContent || placeholder}</div> +{/if} diff --git a/src/lib/website/SingleRecord.svelte b/src/lib/website/SingleRecord.svelte index 593de9f..4324f76 100644 --- a/src/lib/website/SingleRecord.svelte +++ b/src/lib/website/SingleRecord.svelte @@ -1,66 +1,46 @@ <script lang="ts"> - import { getContext, hasContext, type Snippet } from 'svelte'; - import type { Collection } from './data'; - import { data as recordData } from './data'; - import type { UpdateFunction } from './context'; - - type ArrayCollections = { - [K in Collection]: (typeof recordData)[K] extends readonly any[] ? K : never; - }[Collection]; - - type ElementType<C extends Collection> = (typeof recordData)[C] extends readonly (infer U)[] - ? U - : any; - - type Props = - | { - collection: ArrayCollections; - rkey?: ElementType<ArrayCollections>; - data: Record<string, any>; - key: string; - placeholder?: string; - defaultContent?: string; - - editing: Snippet<[any, any]>; - view: Snippet<[any]>; - } - | { - collection: Exclude<Collection, ArrayCollections>; - rkey: ElementType<Exclude<Collection, ArrayCollections>>; - data: Record<string, any>; - key: string; - placeholder?: string; - defaultContent?: string; - - editing: Snippet<[any, any]>; - view: Snippet<[any]>; - }; + import { getContext, setContext, type Snippet } from 'svelte'; + import { parseUri } from './data'; + import type { UpdateFunction, UpdateRecordFunction } from './context'; + import { putRecord } from '$lib/oauth/atproto'; let { data, - editing, - view - }: Props = $props(); + child + }: { + data: Record<string, any>; + child: Snippet<[any]>; + } = $props(); + + let updateRecordFunctions: UpdateRecordFunction[] = $state([]); + setContext('updateRecord', updateRecordFunctions); const updateFunctions = getContext('updateFunctions') as UpdateFunction[]; const update = async () => { - console.log('update', data); - return true; + const updated: Record<string, any> = {}; + + for (const updateFunction of updateRecordFunctions) { + const updatedPart = await updateFunction(); + for (const key in updatedPart) { + updated[key] = updatedPart[key]; + data.value[key] = updatedPart[key]; + } + } + + if (Object.keys(updated).length > 0) { + data.value.updatedAt = new Date().toISOString(); + const { collection, rkey } = parseUri(data.uri); + await putRecord({ collection, rkey, record: data.value }); + return true; + } + + return false; }; - if(updateFunctions) { + if (updateFunctions) { updateFunctions.push(update); } - - function willUpdate() { - console.log('willUpdate', data); - return true; - } </script> -{#if hasContext('isEditing')} - {@render editing(data.value, willUpdate)} -{:else} - {@render view(data.value)} -{/if} +{@render child(data.value)} diff --git a/src/lib/website/Test.svelte b/src/lib/website/Test.svelte index c58ec0d..68183c4 100644 --- a/src/lib/website/Test.svelte +++ b/src/lib/website/Test.svelte @@ -1,11 +1,37 @@ -<script> +<script lang="ts"> + import { Input } from "@fuxui/base"; + import { getContext } from "svelte"; + import type { UpdateFunction, UpdateRecordFunction } from "./context"; + import PlainTextEditor from "./PlainTextEditor.svelte"; + let { data } = $props(); console.log(data); -</script> -<div> - {data.value.label}: - {data.value.description} -</div> + let label = $state(data.label); + let description = $state(data.description); + + let updatedLabel = $state(false); + let updatedDescription = $state(false); + const update = async () => { + const updated: Record<string, any> = {}; + if(updatedLabel) { + updated.label = label; + } + if(updatedDescription) { + updated.description = description; + } + return updated; + }; + + const updateFunctions = getContext('updateRecord') as UpdateRecordFunction[]; + + if(updateFunctions) { + updateFunctions.push(update); + } +</script> +<div class="flex flex-col gap-2"> + <PlainTextEditor class="font-bold" bind:value={label} onchange={() => updatedLabel = true} /> + <PlainTextEditor bind:value={description} onchange={() => updatedDescription = true} /> +</div> \ No newline at end of file diff --git a/src/lib/website/Website.svelte b/src/lib/website/Website.svelte index 5ce22bb..4c9351a 100644 --- a/src/lib/website/Website.svelte +++ b/src/lib/website/Website.svelte @@ -4,10 +4,14 @@ import MarkdownText from './MarkdownText.svelte'; import SingleRecord from './SingleRecord.svelte'; import Test from './Test.svelte'; + import PlainText from './PlainText.svelte'; + import { setContext } from 'svelte'; + import Link from './Link.svelte'; + import PlainTextItem from './PlainTextItem.svelte'; let { data } = $props(); - console.log(data); + setContext('data', data); </script> <div class="mx-auto max-w-2xl py-16"> @@ -24,25 +28,33 @@ defaultContent="Hello there" /> --> - <div class="flex flex-col gap-4"> - <List collection="link.flo-bit.dev" {data}> - {#snippet item(item)} - <SingleRecord data={item}> - {#snippet editing(data)} - <div class="flex flex-col gap-2"> - <Input type="text" bind:value={data.label} /> - <Input type="text" bind:value={data.description} /> - </div> - {/snippet} - - {#snippet view(data)} - <div class="flex flex-col gap-2"> - <div class="text-lg font-bold">{data.label}</div> - <div>{data.description}</div> - </div> - {/snippet} - </SingleRecord> + <PlainTextItem collection="dev.flo-bit.about" rkey="test" key="about" {data} placeholder="Write something about yourself..." /> + + <div class="relative flex flex-col gap-4"> + <List collection="link.flo-bit.dev"> + {#snippet item(data, deleteItem)} + <Link {data} {deleteItem} /> + {/snippet} + + {#snippet addItem(add)} + <button class="absolute right-0 bottom-0" onclick={add}>Add</button> {/snippet} </List> </div> + <!-- + <div class="relative flex flex-col gap-4 mt-8"> + <List collection="link.flo-bit.test"> + {#snippet item(data, deleteItem)} + <Link {data} {deleteItem} /> + {/snippet} + + {#snippet addItem(add)} + <button class="absolute right-0 bottom-0" onclick={add}>Add</button> + {/snippet} + + {#snippet empty()} + <div>Empty list</div> + {/snippet} + </List> + </div> --> </div> diff --git a/src/lib/website/context.ts b/src/lib/website/context.ts index 2d3d90d..d3f374b 100644 --- a/src/lib/website/context.ts +++ b/src/lib/website/context.ts @@ -1 +1,3 @@ export type UpdateFunction = () => boolean | Promise<boolean>; + +export type UpdateRecordFunction = () => Record<string, any> | Promise<Record<string, any>>; \ No newline at end of file diff --git a/src/lib/website/data.ts b/src/lib/website/data.ts index 04ae62e..1af6082 100644 --- a/src/lib/website/data.ts +++ b/src/lib/website/data.ts @@ -1,6 +1,6 @@ // collections and records we want to grab export const data = { - 'dev.flo-bit.about': ['self'], + 'dev.flo-bit.about': ['self', 'test'], 'dev.flo-bit.test': ['self'], 'link.flo-bit.dev': 'all' } as const; @@ -15,3 +15,9 @@ export type CollectionRecord<C extends Collection> = (typeof data)[C] extends readonly (infer U)[] ? U // yes → U is the element type (e.g. "self") : any; // no → value must have been "all" + +export function parseUri(uri: string) { + // at://did:plc:257wekqxg4hyapkq6k47igmp/link.flo-bit.dev/3lnblfznvhr2a + const [did, collection, rkey] = uri.split('/').slice(2); + return { did, collection, rkey }; +} \ No newline at end of file diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index a635517..4ad9b42 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -14,7 +14,6 @@ export async function load() { const downloadedData = {} as DownloadedData; - // 3️⃣ Narrow Object.keys back to your Collection union for (const collection of Object.keys(data) as Collection[]) { const cfg = data[collection]; diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index e9f0343..a206474 100644 --- a/src/routes/edit/+page.svelte +++ b/src/routes/edit/+page.svelte @@ -23,9 +23,7 @@ class="absolute right-2 bottom-2" onclick={() => { for (const updateFunction of updateFunctions) { - if (updateFunction()) { - console.log('updated'); - } + updateFunction(); } }} >