Attic is a cozy space with lofty ambitions. attic.social
11
fork

Configure Feed

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

bookmark edit + delete actions

+168 -64
+5 -6
src/css/components/bookmark.css
··· 20 20 box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.5); 21 21 } 22 22 23 - & > :is(h2, h3) { 23 + & > :is(h2, h3, .flex) { 24 24 font-size: var(--font-size-3); 25 25 grid-column: 1/ -1; 26 26 ··· 52 52 pointer-events: none; 53 53 } 54 54 55 - & > form { 55 + & form { 56 56 display: contents; 57 + } 58 + 59 + & :is(button, form) { 57 60 z-index: 1; 58 - 59 - & > button { 60 - z-index: 1; 61 - } 62 61 } 63 62 }
+6 -1
src/css/components/form.css
··· 25 25 grid-column: 1 / -1; 26 26 } 27 27 28 - &[action*="create"] { 28 + & > :is(.flex) { 29 + inline-size: 100%; 30 + } 31 + 32 + &[action*="editBookmark"], 33 + &[action*="createBookmark"] { 29 34 & input { 30 35 inline-size: 100%; 31 36 }
+2 -1
src/lib/valibot.ts
··· 1 1 import { Client } from "@atcute/client"; 2 2 import type { Did, Handle } from "@atcute/lexicons"; 3 + import type { XRPCProcedures, XRPCQueries } from "@atcute/lexicons/ambient"; 3 4 import { isDid, isHandle } from "@atcute/lexicons/syntax"; 4 5 import { OAuthSession } from "@atcute/oauth-node-client"; 5 6 import * as v from "valibot"; ··· 23 24 24 25 export const PrivateUserSchema = v.object({ 25 26 ...UserSchema, 26 - client: v.instance(Client), 27 + client: v.instance(Client<XRPCQueries, XRPCProcedures>), 27 28 session: v.instance(OAuthSession), 28 29 }); 29 30 export type PrivateUserData = v.InferOutput<typeof PrivateUserSchema>;
+54 -11
src/routes/bookmarks/[did=did]/+page.server.ts
··· 1 1 import { isAuthEvent } from "$lib/types"; 2 2 import { parseBookmark } from "$lib/valibot"; 3 - import { Client } from "@atcute/client"; 4 3 import * as TID from "@atcute/tid"; 5 4 import { type Actions, fail } from "@sveltejs/kit"; 6 5 7 6 export const actions = { 8 - delete: async (event) => { 7 + deleteBookmark: async (event) => { 9 8 if (isAuthEvent(event) === false) { 10 9 throw new Error(); 11 10 } ··· 14 13 return; 15 14 } 16 15 const formData = await event.request.formData(); 17 - const rpc = new Client({ handler: user.session }); 18 - const result = await rpc.post("com.atproto.repo.deleteRecord", { 16 + const result = await user.client.post("com.atproto.repo.deleteRecord", { 19 17 input: { 20 18 repo: user.did, 21 19 collection: "social.attic.bookmark.entity", ··· 30 28 } 31 29 return { success: true }; 32 30 }, 33 - create: async (event) => { 31 + createBookmark: async (event) => { 34 32 if (isAuthEvent(event) === false) { 35 33 throw new Error(); 36 34 } ··· 44 42 try { 45 43 // @ts-ignore normalize url 46 44 const parsed = URL.parse(formData.get("url")); 47 - // if (parsed === null) { 48 - // throw new Error("Invalid URL"); 49 - // } 50 45 data.url = parsed?.href ?? data.url; 51 46 const record = parseBookmark(data); 52 - const rpc = new Client({ handler: user.session }); 53 - const result = await rpc.post("com.atproto.repo.putRecord", { 47 + const result = await user.client.post("com.atproto.repo.putRecord", { 54 48 input: { 55 49 repo: user.did, 56 50 collection: "social.attic.bookmark.entity", ··· 69 63 } 70 64 return fail(400, { 71 65 data: Object.fromEntries(formData), 72 - action: "create", 66 + action: "createBookmark", 67 + error, 68 + }); 69 + } 70 + }, 71 + editBookmark: async (event) => { 72 + if (isAuthEvent(event) === false) { 73 + throw new Error(); 74 + } 75 + if (event.locals.user === undefined) { 76 + return; 77 + } 78 + const { user } = event.locals; 79 + const formData = await event.request.formData(); 80 + try { 81 + const response = await user.client.get("com.atproto.repo.getRecord", { 82 + params: { 83 + repo: user.did, 84 + collection: "social.attic.bookmark.entity", 85 + rkey: String(formData.get("rkey")), 86 + }, 87 + }); 88 + if (response.ok === false) { 89 + throw new Error(); 90 + } 91 + const record = response.data.value; 92 + // @ts-ignore normalize url 93 + record.url = URL.parse(formData.get("url"))?.href ?? ""; 94 + record.title = formData.get("title"); 95 + parseBookmark(record); 96 + const result = await user.client.post("com.atproto.repo.putRecord", { 97 + input: { 98 + repo: user.did, 99 + collection: "social.attic.bookmark.entity", 100 + rkey: String(formData.get("rkey")), 101 + record, 102 + }, 103 + }); 104 + if (result.ok === false) { 105 + throw new Error(); 106 + } 107 + return { success: true }; 108 + } catch (err) { 109 + let error = "Failed to edit bookmark."; 110 + if (err instanceof Error) { 111 + error = err.message; 112 + } 113 + return fail(400, { 114 + data: Object.fromEntries(formData), 115 + action: "editBookmark", 73 116 error, 74 117 }); 75 118 }
+101 -45
src/routes/bookmarks/[did=did]/+page.svelte
··· 5 5 6 6 const isSelf = $derived(data.user && params.did === data.user.did); 7 7 8 + let editData: null | (typeof data.bookmarks)[number] = $state(null); 9 + 10 + let editDialog: HTMLDialogElement | null = $state(null); 8 11 let createDialog: HTMLDialogElement | null = $state(null); 9 12 10 13 $effect(() => { 11 - if (form?.action === "create" && "error" in form) { 14 + if (form?.action === "editBookmark" && "error" in form) { 15 + if (editDialog?.open === false) { 16 + editDialog?.showModal(); 17 + } 18 + } 19 + }); 20 + 21 + $effect(() => { 22 + if (form?.action === "createBookmark" && "error" in form) { 12 23 if (createDialog?.open === false) { 13 24 createDialog?.showModal(); 14 25 } ··· 17 28 18 29 // [TODO] better error dialog? 19 30 $effect(() => { 20 - if (form?.action === "delete") { 31 + if (form?.action === "deleteBookmark") { 21 32 if (form.error) alert(form.error); 22 33 } 23 34 }); ··· 38 49 39 50 <h1>{data.profile.displayName}</h1> 40 51 52 + {#snippet closeButton(dialog?: HTMLDialogElement)} 53 + <button 54 + type="button" 55 + command="close" 56 + commandfor={dialog?.id} 57 + onclick={(ev) => { 58 + ev.preventDefault(); 59 + dialog?.close(); 60 + }} 61 + > 62 + <span class="visually-hidden">close</span> 63 + </button> 64 + {/snippet} 65 + 66 + {#snippet urlInput(value = "")} 67 + <label for="url">URL</label> 68 + <input type="url" id="url" name="url" maxlength="1280" {value} required /> 69 + {/snippet} 70 + 71 + {#snippet titleInput(value = "")} 72 + <label for="title">Title</label> 73 + <input 74 + type="text" 75 + id="title" 76 + name="title" 77 + maxlength="1280" 78 + {value} 79 + required 80 + /> 81 + {/snippet} 82 + 41 83 {#if isSelf} 42 84 <dialog id="create" bind:this={createDialog}> 43 - <button 44 - type="button" 45 - commandfor="create" 46 - command="close" 47 - onclick={(ev) => { 48 - ev.preventDefault(); 49 - createDialog?.close(); 50 - }} 51 - > 52 - <span class="visually-hidden">close</span> 53 - </button> 54 - <form method="POST" action="?/create"> 55 - <h2>Create bookmark</h2> 85 + {@render closeButton(createDialog)} 86 + <form method="POST" action="?/createBookmark"> 87 + <h2>New bookmark</h2> 56 88 <p>Please remember: all atproto data is public.</p> 57 - {#if form?.action === "create" && form?.error} 89 + {#if form?.action === "createBookmark" && form?.error} 58 90 <p class="error">{form.error}</p> 59 91 {/if} 60 - <label for="url">URL</label> 61 - <input 62 - type="url" 63 - id="url" 64 - name="url" 65 - maxlength="1280" 66 - value={form?.action === "create" ? form.data.url : ""} 67 - required 68 - /> 69 - <label for="title">Title</label> 92 + {@render urlInput( 93 + form?.action === "createBookmark" ? form?.data?.url.toString() : "", 94 + )} 95 + {@render titleInput( 96 + form?.action === "createBookmark" ? form?.data?.title.toString() : "", 97 + )} 98 + <button type="submit">Create</button> 99 + </form> 100 + </dialog> 101 + <dialog id="edit" bind:this={editDialog}> 102 + {@render closeButton(editDialog)} 103 + <form method="POST" action="?/editBookmark"> 70 104 <input 71 - type="text" 72 - id="title" 73 - name="title" 74 - maxlength="1280" 75 - value={form?.action === "create" ? form.data.title : ""} 76 - required 105 + type="hidden" 106 + name="rkey" 107 + value={form?.action === "editBookmark" 108 + ? form?.data?.rkey.toString() 109 + : editData?.uri.split("/").at(-1)} 77 110 /> 78 - <button type="submit">Create</button> 111 + <h2>Edit bookmark</h2> 112 + {#if form?.action === "editBookmark" && form?.error} 113 + <p class="error">{form.error}</p> 114 + {/if} 115 + {@render urlInput( 116 + form?.action === "editBookmark" 117 + ? form?.data?.url.toString() 118 + : editData?.url, 119 + )} 120 + {@render titleInput( 121 + form?.action === "editBookmark" 122 + ? form?.data?.title.toString() 123 + : editData?.title, 124 + )} 125 + <div class="flex flex-wrap ai-center jc-between"> 126 + <button type="submit">Save</button> 127 + <button 128 + data-danger 129 + type="submit" 130 + formaction="?/deleteBookmark" 131 + onclick={(ev) => { 132 + if (confirm("Are you sure?")) return; 133 + else ev.preventDefault(); 134 + }}>Delete</button 135 + > 136 + </div> 79 137 </form> 80 138 </dialog> 81 139 {/if} ··· 102 160 </time> 103 161 <code aria-hidden="true">{entry.url}</code> 104 162 {#if isSelf} 105 - <form method="POST" action="?/delete"> 106 - <input 107 - type="hidden" 108 - name="rkey" 109 - value={entry.uri.split("/").at(-1)} 110 - /> 163 + <div class="flex flex-wrap"> 111 164 <button 112 - data-danger 113 - type="submit" 165 + type="button" 114 166 onclick={(ev) => { 115 - if (confirm("Are you sure?")) return; 116 - else ev.preventDefault(); 117 - }}>Delete</button 167 + ev.preventDefault(); 168 + form = null; 169 + editData = entry; 170 + editDialog?.showModal(); 171 + }} 118 172 > 119 - </form> 173 + Edit 174 + </button> 175 + </div> 120 176 {/if} 121 177 </article> 122 178 {/each}