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.

settings page

+234 -159
+1
src/css/components/bookmark.css
··· 8 8 display: flex; 9 9 flex-wrap: wrap; 10 10 justify-content: space-between; 11 + margin-block-end: 10px; 11 12 row-gap: 20px; 12 13 13 14 & > button {
+8 -1
src/hooks.server.ts
··· 2 2 import { acceptsLanguages } from "$lib/negotiation"; 3 3 import { restoreSession } from "$lib/server/session"; 4 4 import { isAuthEvent } from "$lib/types"; 5 - import { error, type Handle } from "@sveltejs/kit"; 5 + import { error, type Handle, redirect } from "@sveltejs/kit"; 6 6 import { sequence } from "@sveltejs/kit/hooks"; 7 7 8 8 /** ··· 33 33 // ) { 34 34 await restoreSession(event); 35 35 // } 36 + 37 + if ( 38 + event.route.id?.startsWith("/(protected)/") && 39 + event.locals.user === undefined 40 + ) { 41 + redirect(307, "/"); 42 + } 36 43 37 44 return resolve(event, { 38 45 filterSerializedResponseHeaders: (name: string, _value: string) => {
+14
src/routes/(protected)/settings/+layout.server.ts
··· 1 + import { redirect } from "@sveltejs/kit"; 2 + import type { LayoutServerLoad } from "./$types"; 3 + 4 + export const load: LayoutServerLoad = async (event) => { 5 + const data = await event.parent(); 6 + const user = data.user; 7 + if (user === undefined) { 8 + redirect(307, "/"); 9 + } 10 + return { 11 + ...data, 12 + user, 13 + }; 14 + };
+89
src/routes/(protected)/settings/+page.server.ts
··· 1 + import { destroySession, updateSession } from "$lib/server/session"; 2 + import { isUserEvent } from "$lib/types"; 3 + import { parseActorProfile } from "$lib/valibot"; 4 + import { type Actions, error, fail, redirect } from "@sveltejs/kit"; 5 + 6 + export const actions = { 7 + displayName: async (event) => { 8 + if (isUserEvent(event) === false) { 9 + error(401); 10 + } 11 + const { user } = event.locals; 12 + try { 13 + const formData = await event.request.formData(); 14 + const record = parseActorProfile({ 15 + displayName: formData.get("displayName"), 16 + }); 17 + const result = await user.client.post("com.atproto.repo.putRecord", { 18 + input: { 19 + repo: user.did, 20 + collection: "social.attic.actor.profile", 21 + rkey: "self", 22 + record, 23 + }, 24 + }); 25 + if (result.ok === false) { 26 + throw new Error(); 27 + } 28 + event.locals.user.displayName = record.displayName; 29 + await updateSession(event, { 30 + did: user.did, 31 + handle: user.handle, 32 + displayName: record.displayName, 33 + }); 34 + return { success: true }; 35 + } catch { 36 + return fail(400, { action: "displayName", error: "Failed to update." }); 37 + } 38 + }, 39 + purge: async (event) => { 40 + if (isUserEvent(event) === false) { 41 + error(401); 42 + } 43 + const { user } = event.locals; 44 + 45 + let cursor: string | undefined; 46 + do { 47 + const response = await user.client.get("com.atproto.repo.listRecords", { 48 + params: { 49 + repo: user.did, 50 + collection: "social.attic.bookmark.entity", 51 + cursor, 52 + }, 53 + }); 54 + if (response.ok === false) { 55 + break; 56 + } 57 + for (const entity of response.data.records) { 58 + const result = await user.client.post("com.atproto.repo.deleteRecord", { 59 + input: { 60 + repo: user.did, 61 + collection: "social.attic.bookmark.entity", 62 + rkey: entity.uri.split("/").at(-1)!, 63 + }, 64 + }); 65 + if (result.ok == false) { 66 + return fail(400, { 67 + action: "purge", 68 + error: "Failed to delete bookmark.", 69 + }); 70 + } 71 + } 72 + cursor = response.data.cursor; 73 + } while (cursor); 74 + 75 + const result = await user.client.post("com.atproto.repo.deleteRecord", { 76 + input: { 77 + repo: user.did, 78 + collection: "social.attic.actor.profile", 79 + rkey: "self", 80 + }, 81 + }); 82 + if (result.ok === false) { 83 + return fail(400, { action: "purge", error: "Failed to purge." }); 84 + } 85 + 86 + await destroySession(event); 87 + redirect(303, "/"); 88 + }, 89 + } satisfies Actions;
+46
src/routes/(protected)/settings/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageProps } from "./$types"; 3 + 4 + let { data, form }: PageProps = $props(); 5 + 6 + const confirmPurge = (ev: SubmitEvent) => { 7 + if (confirm("Are you sure?")) { 8 + return; 9 + } 10 + ev.preventDefault(); 11 + }; 12 + </script> 13 + 14 + <svelte:head> 15 + <title>Settings - Attic</title> 16 + </svelte:head> 17 + 18 + <h1>Settings</h1> 19 + 20 + <form method="POST" action="?/displayName"> 21 + <h2>Attic settings</h2> 22 + {#if form?.action === "displayName" && form?.error} 23 + <p class="error">{form.error}</p> 24 + {/if} 25 + <label for="displayName">Display name</label> 26 + <input 27 + type="text" 28 + id="displayName" 29 + name="displayName" 30 + maxlength="64" 31 + autocomplete="off" 32 + value={data.user.displayName} 33 + required 34 + /> 35 + <button type="submit">Update</button> 36 + </form> 37 + 38 + <form method="POST" action="?/purge" onsubmit={confirmPurge}> 39 + <h2>Purge data</h2> 40 + <p>Delete all Attic records and sign out.</p> 41 + <p class="error">Warning: this cannot be reversed.</p> 42 + {#if form?.action === "purge" && form?.error} 43 + <p class="error">{form.error}</p> 44 + {/if} 45 + <button type="submit" data-danger>Confirm</button> 46 + </form>
+1
src/routes/+layout.svelte
··· 38 38 <a href="/">attic.social</a> 39 39 {#if data.user} 40 40 <a href="/bookmarks/{data.user.did}">bookmarks</a> 41 + <a href="/settings">settings</a> 41 42 {/if} 42 43 </nav> 43 44 </header>
+3 -59
src/routes/+page.server.ts
··· 1 1 import { HANDLE_COOKIE } from "$lib/server/constants"; 2 - import { 3 - destroySession, 4 - startSession, 5 - updateSession, 6 - } from "$lib/server/session"; 7 - import { isAuthEvent, isUserEvent } from "$lib/types"; 8 - import { parseActorProfile } from "$lib/valibot"; 9 - import { type Actions, error, fail, redirect } from "@sveltejs/kit"; 2 + import { destroySession, startSession } from "$lib/server/session"; 3 + import { isAuthEvent } from "$lib/types"; 4 + import { type Actions, fail, redirect } from "@sveltejs/kit"; 10 5 11 6 export const actions = { 12 7 logout: async (event) => { ··· 37 32 return fail(400, { handle, action: "login", error: message }); 38 33 } 39 34 redirect(303, url); 40 - }, 41 - displayName: async (event) => { 42 - if (isUserEvent(event) === false) { 43 - error(401); 44 - } 45 - const { user } = event.locals; 46 - try { 47 - const formData = await event.request.formData(); 48 - const record = parseActorProfile({ 49 - displayName: formData.get("displayName"), 50 - }); 51 - const result = await user.client.post("com.atproto.repo.putRecord", { 52 - input: { 53 - repo: user.did, 54 - collection: "social.attic.actor.profile", 55 - rkey: "self", 56 - record, 57 - }, 58 - }); 59 - if (result.ok === false) { 60 - throw new Error(); 61 - } 62 - event.locals.user.displayName = record.displayName; 63 - await updateSession(event, { 64 - did: user.did, 65 - handle: user.handle, 66 - displayName: record.displayName, 67 - }); 68 - return { success: true }; 69 - } catch { 70 - return fail(400, { action: "displayName", error: "Failed to update." }); 71 - } 72 - }, 73 - purge: async (event) => { 74 - if (isUserEvent(event) === false) { 75 - error(401); 76 - } 77 - const { user } = event.locals; 78 - const result = await user.client.post("com.atproto.repo.deleteRecord", { 79 - input: { 80 - repo: user.did, 81 - collection: "social.attic.actor.profile", 82 - rkey: "self", 83 - }, 84 - }); 85 - // [TODO] delete all bookmarks 86 - if (result.ok) { 87 - await destroySession(event); 88 - redirect(303, "/"); 89 - } 90 - return fail(400, { action: "purge", error: "Failed to purge." }); 91 35 }, 92 36 } satisfies Actions;
+2 -37
src/routes/+page.svelte
··· 126 126 } 127 127 bskySearch(value); 128 128 }); 129 - 130 - const confirmPurge = (ev: SubmitEvent) => { 131 - if (confirm("Are you sure?")) { 132 - return; 133 - } 134 - ev.preventDefault(); 135 - }; 136 129 </script> 137 130 138 131 <svelte:head> ··· 151 144 </div> 152 145 <button type="submit">Sign out</button> 153 146 </form> 154 - <form method="POST" action="?/displayName"> 155 - <h2>Attic settings</h2> 156 - {#if form?.action === "displayName" && form?.error} 157 - <p class="error">{form.error}</p> 158 - {/if} 159 - <label for="displayName">Display name</label> 160 - <input 161 - type="text" 162 - id="displayName" 163 - name="displayName" 164 - maxlength="64" 165 - autocomplete="off" 166 - value={data.user.displayName} 167 - /> 168 - <button type="submit">Update</button> 169 - </form> 170 - <form method="POST" action="?/purge" onsubmit={confirmPurge}> 171 - <h2>Purge data</h2> 172 - <p> 173 - Delete all Attic records and sign out. 174 - <strong>This cannot be reversed.</strong> 175 - </p> 176 - <p class="error">Bookmark purge not implemented yet.</p> 177 - {#if form?.action === "purge" && form?.error} 178 - <p class="error">{form.error}</p> 179 - {/if} 180 - <button type="submit" data-danger>Confirm</button> 181 - </form> 182 147 {:else} 183 148 <form bind:this={loginForm} method="POST" action="?/login"> 184 149 <h2>Sign in</h2> ··· 236 201 </form> 237 202 {/if} 238 203 239 - <p>Attic is extremely early in development! Use a your own risk :)</p> 204 + <p>Attic is extremely early in development! Feedback is welcome :)</p> 240 205 <p> 241 206 <a 242 207 href="https://bsky.app/profile/dbushell.com" 243 208 rel="noopener noreferrer" 244 209 target="_blank">Follow on Bluesky</a 245 - > to keep up. 210 + > to keep up and chat. 246 211 </p>
+70 -62
src/routes/bookmarks/[did=did]/+page.svelte
··· 48 48 }); 49 49 }); 50 50 51 - const isSelf = $derived(data.user && params.did === data.user.did); 51 + const isSelf = $derived((data.user && params.did === data.user.did) || false); 52 52 53 53 let dialogAction = $state(""); 54 54 let dialogEntity: BookmarkEntity | undefined = $state(undefined); ··· 231 231 /> 232 232 </svelte:head> 233 233 234 - <h1>{data.profile.displayName}</h1> 234 + {#if isSelf === false} 235 + <h1>{data.profile.displayName}</h1> 236 + {/if} 237 + 238 + <div class="Bookmarks"> 239 + <div class="Bookmarks-header"> 240 + <svelte:element this={isSelf ? "h1" : "h2"}>Bookmarks</svelte:element> 241 + <div class="flex flex-wrap"> 242 + {#if isSelf} 243 + <button type="button" onclick={() => setupDialog(undefined, true)} 244 + >New</button 245 + > 246 + {/if} 247 + <a href={rssURL.href} class="Button Button-rss" target="_blank"> 248 + <span class="visually-hidden">RSS</span> 249 + </a> 250 + </div> 251 + </div> 252 + {#if bookmarks.length === 0} 253 + <p>Click the NEW button to start adding bookmarks.</p> 254 + <p> 255 + You can share this page with others. All bookmarks are publicly visible. 256 + </p> 257 + {/if} 258 + {#each bookmarks as entry (entry.cid)} 259 + <article id={entry.cid} class="Bookmark"> 260 + <h3> 261 + <a href={entry.url} rel="noopener noreferrer" target="_blank"> 262 + <img 263 + alt="" 264 + width="16" 265 + height="16" 266 + decoding="async" 267 + fetchpriority="low" 268 + loading="lazy" 269 + src="/bookmarks/favicon/{new URL(entry.url).hostname}" 270 + /> 271 + {entry.title} 272 + </a> 273 + {#if isSelf} 274 + <button type="button" onclick={() => setupDialog(entry, true)}> 275 + Edit 276 + </button> 277 + {/if} 278 + </h3> 279 + <time datetime={entry.createdAt}> 280 + {dateFormat.format(new Date(entry.createdAt))} 281 + </time> 282 + <code aria-hidden="true">{entry.url}</code> 283 + {#if entry.tags?.length} 284 + <ul> 285 + {#each entry.tags as tag} 286 + <li>{tag}</li> 287 + {/each} 288 + </ul> 289 + {/if} 290 + </article> 291 + {/each} 292 + {#if nextLoad} 293 + <button 294 + type="button" 295 + onclick={onNextClick} 296 + disabled={nextDisabled} 297 + {@attach nextAttach} 298 + > 299 + Load more... 300 + </button> 301 + {/if} 302 + </div> 235 303 236 304 {#if isSelf} 237 305 {@const create = dialogAction === "createBookmark"} ··· 347 415 </form> 348 416 </dialog> 349 417 {/if} 350 - 351 - <div class="Bookmarks"> 352 - <div class="Bookmarks-header"> 353 - <h2>Bookmarks</h2> 354 - <div class="flex flex-wrap"> 355 - {#if isSelf} 356 - <button type="button" onclick={() => setupDialog(undefined, true)} 357 - >New</button 358 - > 359 - {/if} 360 - <a href={rssURL.href} class="Button Button-rss" target="_blank"> 361 - <span class="visually-hidden">RSS</span> 362 - </a> 363 - </div> 364 - </div> 365 - {#each bookmarks as entry (entry.cid)} 366 - <article id={entry.cid} class="Bookmark"> 367 - <h3> 368 - <a href={entry.url} rel="noopener noreferrer" target="_blank"> 369 - <img 370 - alt="" 371 - width="16" 372 - height="16" 373 - decoding="async" 374 - fetchpriority="low" 375 - loading="lazy" 376 - src="/bookmarks/favicon/{new URL(entry.url).hostname}" 377 - /> 378 - {entry.title} 379 - </a> 380 - {#if isSelf} 381 - <button type="button" onclick={() => setupDialog(entry, true)}> 382 - Edit 383 - </button> 384 - {/if} 385 - </h3> 386 - <time datetime={entry.createdAt}> 387 - {dateFormat.format(new Date(entry.createdAt))} 388 - </time> 389 - <code aria-hidden="true">{entry.url}</code> 390 - {#if entry.tags?.length} 391 - <ul> 392 - {#each entry.tags as tag} 393 - <li>{tag}</li> 394 - {/each} 395 - </ul> 396 - {/if} 397 - </article> 398 - {/each} 399 - {#if nextLoad} 400 - <button 401 - type="button" 402 - onclick={onNextClick} 403 - disabled={nextDisabled} 404 - {@attach nextAttach} 405 - > 406 - Load more... 407 - </button> 408 - {/if} 409 - </div>