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

bookmark create dialog and delete action

dbushell.com a5c2d4b4 7bc7a625

verified
+257 -49
+22 -2
src/css/base/global.css
··· 24 24 [full-start] 25 25 1fr 26 26 [margin-start] 27 - minmax(20px, min(5vi, 100px)) 27 + var(--app-margin) 28 28 [main-start] 29 29 minmax(260px, calc((900 / 16) * 1rem)) 30 30 [main-end] 31 - minmax(20px, min(5vi, 100px)) 31 + var(--app-margin) 32 32 [margin-end] 33 33 1fr 34 34 [full-end]; ··· 89 89 anchor-name: --pointer; 90 90 } 91 91 } 92 + 93 + dialog { 94 + inline-size: min(min(100%, 900px), calc(100vi - (2 * var(--app-margin)))); 95 + 96 + & [command="close"] { 97 + inset-block-start: 20px; 98 + inset-inline-end: 20px; 99 + position: absolute; 100 + z-index: 1; 101 + 102 + &::before { 103 + content: "×" / ""; 104 + } 105 + } 106 + 107 + &::backdrop { 108 + background: rgb(var(--color-brown) / 0.5); 109 + backdrop-filter: blur(5px); 110 + } 111 + }
+2
src/css/base/properties.css
··· 16 16 --color-light-yellow: 255 190 50; 17 17 --color-off-white: 255 234 188; 18 18 --color-red: 222 34 68; 19 + 20 + --app-margin: max(20px, min(5vi, 100px)); 19 21 }
+9
src/css/components/bookmark.css
··· 51 51 white-space: nowrap; 52 52 pointer-events: none; 53 53 } 54 + 55 + & > form { 56 + display: contents; 57 + z-index: 1; 58 + 59 + & > button { 60 + z-index: 1; 61 + } 62 + } 54 63 }
+16 -4
src/css/components/button.css
··· 1 1 button[type] { 2 - /*background: white;*/ 3 - --font-size: var(--font-size-button); 4 2 border: 15px solid transparent; 5 3 border-image: url("/images/button.svg") 15 fill stretch; 6 - block-size: calc(var(--font-size) + 30px); 4 + block-size: calc(var(--font-size-button) + 30px); 5 + color: rgb(var(--color-black)); 7 6 font-family: var(--font-family-2); 8 - font-size: var(--font-size); 7 + font-size: var(--font-size-button); 9 8 font-weight: 400; 10 9 inline-size: fit-content; 11 10 line-height: 1; ··· 17 16 18 17 &:hover { 19 18 border-image-source: url("/images/button-hover.svg"); 19 + } 20 + 21 + &[data-danger] { 22 + &:not(:hover) { 23 + border-image-source: url("/images/button-danger.svg"); 24 + color: rgb(var(--color-red)); 25 + } 26 + } 27 + 28 + :where(.Bookmark) & { 29 + --font-size-button: var(--font-size-3); 30 + border-width: 10px; 31 + block-size: calc(var(--font-size-button) + 20px); 20 32 } 21 33 }
-9
src/css/components/form.css
··· 81 81 grid-column: 2 / 3; 82 82 } 83 83 } 84 - 85 - &[action*="purge"] { 86 - & button { 87 - &:not(:hover) { 88 - border-image-source: url("/images/button-danger.svg"); 89 - color: rgb(var(--color-red)); 90 - } 91 - } 92 - } 93 84 }
+1 -1
src/css/components/input.css
··· 1 1 input { 2 - /*background: white;*/ 3 2 border: 15px solid transparent; 4 3 border-image: url("/images/input.svg") 15 fill stretch; 5 4 block-size: calc(var(--font-size-button) + 30px); 5 + color: rgb(var(--color-black)); 6 6 font-family: var(--font-family-1); 7 7 font-size: var(--font-size-3); 8 8 font-weight: 400;
+2
src/css/main.css
··· 12 12 @import "components/bookmark.css" layer(base); 13 13 14 14 @import "utility/hidden.css" layer(base); 15 + @import "utility/flex.css" layer(base); 15 16 16 17 .error { 17 18 color: rgb(var(--color-red)); ··· 32 33 position-anchor: --pointer; 33 34 position: fixed; 34 35 user-select: none; 36 + z-index: 999; 35 37 36 38 @supports not (inset: anchor(start)) { 37 39 display: none;
+84
src/css/utility/flex.css
··· 1 + .flex { 2 + display: flex; 3 + gap: 10px; 4 + } 5 + 6 + .flex-wrap { 7 + flex-wrap: wrap; 8 + } 9 + 10 + .ac-around { 11 + align-content: space-around; 12 + } 13 + .ac-between { 14 + align-content: space-between; 15 + } 16 + .ac-evenly { 17 + align-content: space-evenly; 18 + } 19 + .ac-center { 20 + align-content: center; 21 + } 22 + .ac-end { 23 + align-content: end; 24 + } 25 + .ac-start { 26 + align-content: start; 27 + } 28 + .ac-stretch { 29 + align-content: stretch; 30 + } 31 + 32 + .ai-baseline { 33 + align-items: baseline; 34 + } 35 + .ai-center { 36 + align-items: center; 37 + } 38 + .ai-end { 39 + align-items: end; 40 + } 41 + .ai-start { 42 + align-items: start; 43 + } 44 + .ai-stretch { 45 + align-items: stretch; 46 + } 47 + 48 + .jc-around { 49 + justify-content: space-around; 50 + } 51 + .jc-between { 52 + justify-content: space-between; 53 + } 54 + .jc-evenly { 55 + justify-content: space-evenly; 56 + } 57 + .jc-center { 58 + justify-content: center; 59 + } 60 + .jc-end { 61 + justify-content: end; 62 + } 63 + .jc-start { 64 + justify-content: start; 65 + } 66 + .jc-stretch { 67 + justify-content: stretch; 68 + } 69 + 70 + .ji-baseline { 71 + justify-items: baseline; 72 + } 73 + .ji-center { 74 + justify-items: center; 75 + } 76 + .ji-end { 77 + justify-items: end; 78 + } 79 + .ji-start { 80 + justify-items: start; 81 + } 82 + .ji-stretch { 83 + justify-items: stretch; 84 + }
+2
src/lib/valibot.ts
··· 56 56 title: v.pipe( 57 57 v.string(), 58 58 v.trim(), 59 + v.minLength(1), 59 60 v.maxLength(2560), 60 61 v.maxGraphemes(256), 61 62 ), ··· 63 64 v.pipe( 64 65 v.string(), 65 66 v.trim(), 67 + v.minLength(1), 66 68 v.maxLength(320), 67 69 v.maxGraphemes(32), 68 70 ),
+1 -1
src/routes/+page.svelte
··· 176 176 {#if form?.action === "purge" && form?.error} 177 177 <p class="error">{form.error}</p> 178 178 {/if} 179 - <button type="submit">Confirm</button> 179 + <button type="submit" data-danger>Confirm</button> 180 180 </form> 181 181 {:else} 182 182 <form bind:this={loginForm} method="POST" action="?/login">
+37 -5
src/routes/bookmarks/[did=did]/+page.server.ts
··· 5 5 import { type Actions, fail } from "@sveltejs/kit"; 6 6 7 7 export const actions = { 8 + delete: async (event) => { 9 + if (isAuthEvent(event) === false) { 10 + throw new Error(); 11 + } 12 + const { user } = event.locals; 13 + if (user === undefined) { 14 + return; 15 + } 16 + const formData = await event.request.formData(); 17 + const rpc = new Client({ handler: user.session }); 18 + const result = await rpc.post("com.atproto.repo.deleteRecord", { 19 + input: { 20 + repo: user.did, 21 + collection: "social.attic.bookmark.entity", 22 + rkey: String(formData.get("rkey")), 23 + }, 24 + }); 25 + if (result.ok === false) { 26 + return fail(400, { 27 + action: "delete", 28 + error: "Failed to delete bookmark.", 29 + }); 30 + } 31 + return { success: true }; 32 + }, 8 33 create: async (event) => { 9 34 if (isAuthEvent(event) === false) { 10 35 throw new Error(); ··· 16 41 const formData = await event.request.formData(); 17 42 formData.set("createdAt", new Date().toISOString()); 18 43 const data = Object.fromEntries(formData); 19 - // @ts-ignore normalize url 20 - data.url = URL.parse(formData.get("url"))?.href; 21 44 try { 45 + // @ts-ignore normalize url 46 + const parsed = URL.parse(formData.get("url")); 47 + // if (parsed === null) { 48 + // throw new Error("Invalid URL"); 49 + // } 50 + data.url = parsed?.href ?? data.url; 22 51 const record = parseBookmark(data); 23 52 const rpc = new Client({ handler: user.session }); 24 53 const result = await rpc.post("com.atproto.repo.putRecord", { ··· 34 63 } 35 64 return { success: true }; 36 65 } catch (err) { 37 - console.log(err); 66 + let error = "Failed to create bookmark."; 67 + if (err instanceof Error) { 68 + error = err.message; 69 + } 38 70 return fail(400, { 39 - data, 71 + data: Object.fromEntries(formData), 40 72 action: "create", 41 - error: "Failed to create bookmark.", 73 + error, 42 74 }); 43 75 } 44 76 },
+81 -27
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 createDialog: HTMLDialogElement | null = $state(null); 9 + 10 + $effect(() => { 11 + if (form?.action === "create" && "error" in form) { 12 + if (createDialog?.open === false) { 13 + createDialog?.showModal(); 14 + } 15 + } 16 + }); 17 + 18 + // [TODO] better error dialog? 19 + $effect(() => { 20 + if (form?.action === "delete") { 21 + if (form.error) alert(form.error); 22 + } 23 + }); 24 + 8 25 const dateFormat = new Intl.DateTimeFormat(undefined, { 9 26 dateStyle: "medium", 10 27 timeStyle: "short", ··· 22 39 <h1>{data.profile.displayName}</h1> 23 40 24 41 {#if isSelf} 25 - <form method="POST" action="?/create"> 26 - <h2>Create bookmark</h2> 27 - <p>Please remember: all atproto data is public.</p> 28 - {#if form?.action === "create" && form?.error} 29 - <p class="error">{form.error}</p> 30 - {/if} 31 - <label for="url">URL</label> 32 - <input 33 - type="url" 34 - id="url" 35 - name="url" 36 - maxlength="1280" 37 - value={form?.action === "create" ? form.data.url : ""} 38 - required 39 - /> 40 - <label for="title">Title</label> 41 - <input 42 - type="text" 43 - id="title" 44 - name="title" 45 - maxlength="1280" 46 - value={form?.action === "create" ? form.data.title : ""} 47 - required 48 - /> 49 - <button type="submit">Create</button> 50 - </form> 42 + <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> 56 + <p>Please remember: all atproto data is public.</p> 57 + {#if form?.action === "create" && form?.error} 58 + <p class="error">{form.error}</p> 59 + {/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> 70 + <input 71 + type="text" 72 + id="title" 73 + name="title" 74 + maxlength="1280" 75 + value={form?.action === "create" ? form.data.title : ""} 76 + required 77 + /> 78 + <button type="submit">Create</button> 79 + </form> 80 + </dialog> 51 81 {/if} 52 82 53 83 {#if data.bookmarks.length} 54 84 <div class="Bookmarks"> 55 - <h2>Bookmarks</h2> 85 + <div class="flex flex-wrap ai-center jc-between"> 86 + <h2>Bookmarks</h2> 87 + {#if isSelf} 88 + <button type="button" onclick={() => createDialog?.showModal()}> 89 + New 90 + </button> 91 + {/if} 92 + </div> 56 93 {#each data.bookmarks as entry (entry.cid)} 57 94 <article id={entry.cid} class="Bookmark"> 58 95 <h3> ··· 64 101 {dateFormat.format(new Date(entry.createdAt))} 65 102 </time> 66 103 <code aria-hidden="true">{entry.url}</code> 104 + {#if isSelf} 105 + <form method="POST" action="?/delete"> 106 + <input 107 + type="hidden" 108 + name="rkey" 109 + value={entry.uri.split("/").at(-1)} 110 + /> 111 + <button 112 + data-danger 113 + type="submit" 114 + onclick={(ev) => { 115 + if (confirm("Are you sure?")) return; 116 + else ev.preventDefault(); 117 + }}>Delete</button 118 + > 119 + </form> 120 + {/if} 67 121 </article> 68 122 {/each} 69 123 </div>