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

fetch bookmark title

dbushell.com 2ca2aaa2 80acba46

verified
+150 -37
+4
src/css/components/button.css
··· 18 18 border-image-source: var(--button-border-hover); 19 19 } 20 20 21 + &:disabled { 22 + opacity: 0.5; 23 + } 24 + 21 25 &[data-danger] { 22 26 &:not(:hover) { 23 27 border-image-source: var(--button-border-danger);
+17 -1
src/css/components/form.css
··· 29 29 inline-size: 100%; 30 30 } 31 31 32 - &[action*="editBookmark"], 32 + &[action*="editBookmark"] { 33 + & input { 34 + inline-size: 100%; 35 + } 36 + } 37 + 33 38 &[action*="createBookmark"] { 34 39 & input { 35 40 inline-size: 100%; 41 + } 42 + 43 + & div:has(input[name="title"]) { 44 + display: grid; 45 + inline-size: 100%; 46 + grid-template-columns: 1fr auto; 47 + gap: 10px; 48 + 49 + & input { 50 + grid-column: 1; 51 + } 36 52 } 37 53 } 38 54
+5
src/css/components/input.css
··· 9 9 inline-size: min(100%, 400px); 10 10 line-height: var(--line-height-1); 11 11 padding: 0; 12 + 13 + &::placeholder { 14 + color: rgb(var(--color-black) / 0.5); 15 + opacity: 1; 16 + } 12 17 }
+10
src/lib/types.ts
··· 4 4 platform: App.Platform; 5 5 }; 6 6 7 + export type UserEvent = AuthEvent & { 8 + locals: App.Locals & { 9 + user: NonNullable<App.Locals["user"]>; 10 + }; 11 + }; 12 + 7 13 export const isAuthEvent = (event: RequestEvent): event is AuthEvent => { 8 14 return event.platform?.env !== undefined; 9 15 }; 16 + 17 + export const isUserEvent = (event: RequestEvent): event is UserEvent => { 18 + return isAuthEvent(event) && event.locals.user !== undefined; 19 + };
+6 -13
src/routes/+page.server.ts
··· 4 4 startSession, 5 5 updateSession, 6 6 } from "$lib/server/session"; 7 - import { isAuthEvent } from "$lib/types"; 7 + import { isAuthEvent, isUserEvent } from "$lib/types"; 8 8 import { parseActorProfile } from "$lib/valibot"; 9 - import { Client } from "@atcute/client"; 10 - import { type Actions, fail, redirect } from "@sveltejs/kit"; 9 + import { type Actions, error, fail, redirect } from "@sveltejs/kit"; 11 10 12 11 export const actions = { 13 12 logout: async (event) => { ··· 34 33 redirect(303, url); 35 34 }, 36 35 displayName: async (event) => { 37 - if (isAuthEvent(event) === false) { 38 - throw new Error(); 39 - } 40 - if (event.locals.user === undefined) { 41 - return; 36 + if (isUserEvent(event) === false) { 37 + error(401); 42 38 } 43 39 const { user } = event.locals; 44 40 try { ··· 69 65 } 70 66 }, 71 67 purge: async (event) => { 72 - if (isAuthEvent(event) === false) { 73 - throw new Error(); 68 + if (isUserEvent(event) === false) { 69 + error(401); 74 70 } 75 71 const { user } = event.locals; 76 - if (user === undefined) { 77 - return; 78 - } 79 72 const result = await user.client.post("com.atproto.repo.deleteRecord", { 80 73 input: { 81 74 repo: user.did,
+8 -17
src/routes/bookmarks/[did=did]/+page.server.ts
··· 1 - import { isAuthEvent } from "$lib/types"; 1 + import { isUserEvent } from "$lib/types"; 2 2 import { parseBookmark } from "$lib/valibot"; 3 3 import * as TID from "@atcute/tid"; 4 - import { type Actions, fail } from "@sveltejs/kit"; 4 + import { type Actions, error, fail } from "@sveltejs/kit"; 5 5 import type { PageServerLoad } from "./$types"; 6 6 7 7 export const load: PageServerLoad = async ({ locals }) => { ··· 12 12 13 13 export const actions = { 14 14 deleteBookmark: async (event) => { 15 - if (isAuthEvent(event) === false) { 16 - throw new Error(); 15 + if (isUserEvent(event) === false) { 16 + error(401); 17 17 } 18 18 const { user } = event.locals; 19 - if (user === undefined) { 20 - return; 21 - } 22 19 const formData = await event.request.formData(); 23 20 const result = await user.client.post("com.atproto.repo.deleteRecord", { 24 21 input: { ··· 36 33 return { success: true }; 37 34 }, 38 35 createBookmark: async (event) => { 39 - if (isAuthEvent(event) === false) { 40 - throw new Error(); 41 - } 42 - if (event.locals.user === undefined) { 43 - return; 36 + if (isUserEvent(event) === false) { 37 + error(401); 44 38 } 45 39 const { user } = event.locals; 46 40 const formData = await event.request.formData(); ··· 76 70 } 77 71 }, 78 72 editBookmark: async (event) => { 79 - if (isAuthEvent(event) === false) { 80 - throw new Error(); 81 - } 82 - if (event.locals.user === undefined) { 83 - return; 73 + if (isUserEvent(event) === false) { 74 + error(401); 84 75 } 85 76 const { user } = event.locals; 86 77 const formData = await event.request.formData();
+54 -5
src/routes/bookmarks/[did=did]/+page.svelte
··· 1 1 <script lang="ts"> 2 + import type { EventHandler } from "svelte/elements"; 2 3 import type { PageProps } from "./$types"; 3 4 4 5 let { data, form, params }: PageProps = $props(); ··· 6 7 const isSelf = $derived(data.user && params.did === data.user.did); 7 8 8 9 let editData: null | (typeof data.bookmarks)[number] = $state(null); 9 - 10 10 let editDialog: HTMLDialogElement | null = $state(null); 11 11 let createDialog: HTMLDialogElement | null = $state(null); 12 + let createURL: URL | null = $state(null); 12 13 13 14 const dateFormat = $derived( 14 15 new Intl.DateTimeFormat(data.locale, { ··· 17 18 }), 18 19 ); 19 20 21 + const onInputURL: EventHandler<Event, HTMLInputElement> = (ev) => { 22 + const dialog = ev.currentTarget.closest("dialog"); 23 + if (dialog !== createDialog) return; 24 + createURL = URL.parse(ev.currentTarget.value); 25 + }; 26 + 27 + const onFetchTitle = async (ev: MouseEvent) => { 28 + if (createURL === null) return; 29 + if (createDialog === null) return; 30 + const titleInput = 31 + createDialog.querySelector<HTMLInputElement>('[name="title"]'); 32 + if (titleInput === null) return; 33 + const formData = new FormData(); 34 + formData.set("url", createURL.href); 35 + const button = ev.target as HTMLButtonElement; 36 + button.disabled = true; 37 + try { 38 + const response = await fetch("/bookmarks/title", { 39 + method: "POST", 40 + body: formData, 41 + }); 42 + const { title } = await response.json(); 43 + const template = document.createElement("template"); 44 + template.innerHTML = title; 45 + titleInput.value = template.content.textContent; 46 + } catch { 47 + // Whatever... 48 + } finally { 49 + button.disabled = false; 50 + } 51 + }; 52 + 20 53 $effect(() => { 21 54 if (form?.action === "editBookmark" && "error" in form) { 22 55 if (editDialog?.open === false) { ··· 67 100 68 101 {#snippet urlInput(value = "")} 69 102 <label for="url">URL</label> 70 - <input type="url" id="url" name="url" maxlength="1280" {value} required /> 103 + <input 104 + type="url" 105 + id="url" 106 + name="url" 107 + maxlength="1280" 108 + placeholder="https://..." 109 + {value} 110 + oninput={onInputURL} 111 + required 112 + /> 71 113 {/snippet} 72 114 73 115 {#snippet titleInput(value = "")} ··· 94 136 {@render urlInput( 95 137 form?.action === "createBookmark" ? form?.data?.url.toString() : "", 96 138 )} 97 - {@render titleInput( 98 - form?.action === "createBookmark" ? form?.data?.title.toString() : "", 99 - )} 139 + <div> 140 + {@render titleInput( 141 + form?.action === "createBookmark" ? form?.data?.title.toString() : "", 142 + )} 143 + <button 144 + type="button" 145 + onclick={onFetchTitle} 146 + disabled={createURL === null}>Fetch</button 147 + > 148 + </div> 100 149 <button type="submit">Create</button> 101 150 </form> 102 151 </dialog>
+6 -1
src/routes/bookmarks/favicon/[hostname]/+server.ts
··· 1 - import { redirect } from "@sveltejs/kit"; 1 + import { error, redirect } from "@sveltejs/kit"; 2 2 import type { RequestHandler } from "./$types"; 3 3 4 4 const copyHeaders = [ ··· 10 10 ]; 11 11 12 12 export const GET: RequestHandler = async (event) => { 13 + if ( 14 + event.request.headers.get("Sec-Fetch-Site") !== "same-origin" 15 + ) { 16 + error(401); 17 + } 13 18 const url = URL.parse(`https://${event.params.hostname}`); 14 19 if (url === null) { 15 20 return redirect(303, "/images/favicon.svg");
+40
src/routes/bookmarks/title/+server.ts
··· 1 + import { isUserEvent } from "$lib/types"; 2 + import { error, json } from "@sveltejs/kit"; 3 + import type { RequestHandler } from "./$types"; 4 + 5 + export const POST: RequestHandler = async (event) => { 6 + if ( 7 + isUserEvent(event) === false || 8 + event.request.headers.get("Sec-Fetch-Site") !== "same-origin" 9 + ) { 10 + error(401); 11 + } 12 + try { 13 + const formData = await event.request.formData(); 14 + const url = new URL(String(formData.get("url"))); 15 + const origin = new URL(event.platform.env.ORIGIN); 16 + if (url.host === origin.host) { 17 + return json({ title: "Attic" }); 18 + } 19 + const response = await event.fetch(url, { 20 + signal: AbortSignal.timeout(5000), 21 + }); 22 + if ( 23 + response.headers.get("Content-Type")?.startsWith("text/html") === false 24 + ) { 25 + throw new Error(); 26 + } 27 + const html = await response.text(); 28 + const match = html.match(/<title>(.+?)<\/title>/); 29 + if (match === null) { 30 + throw new Error(); 31 + } 32 + const title = match[1].trim(); 33 + if (title.length < 1 || title.length > 2560) { 34 + throw new Error(); 35 + } 36 + return json({ title }); 37 + } catch { 38 + return json({ title: "Untitled" }); 39 + } 40 + };