tangled
alpha
login
or
join now
dbushell.com
/
attic.social
12
fork
atom
Attic is a cozy space with lofty ambitions.
attic.social
12
fork
atom
overview
issues
pulls
pipelines
fetch bookmark title
dbushell.com
2 weeks ago
2ca2aaa2
80acba46
verified
This commit was signed with the committer's
known signature
.
dbushell.com
SSH Key Fingerprint:
SHA256:Sj5AfJ6VbC0PEnnQD2kGGEiGFwHdFBS/ypN5oifzzFI=
+150
-37
9 changed files
expand all
collapse all
unified
split
src
css
components
button.css
form.css
input.css
lib
types.ts
routes
+page.server.ts
bookmarks
[did=did]
+page.server.ts
+page.svelte
favicon
[hostname]
+server.ts
title
+server.ts
+4
src/css/components/button.css
···
18
18
border-image-source: var(--button-border-hover);
19
19
}
20
20
21
21
+
&:disabled {
22
22
+
opacity: 0.5;
23
23
+
}
24
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
32
-
&[action*="editBookmark"],
32
32
+
&[action*="editBookmark"] {
33
33
+
& input {
34
34
+
inline-size: 100%;
35
35
+
}
36
36
+
}
37
37
+
33
38
&[action*="createBookmark"] {
34
39
& input {
35
40
inline-size: 100%;
41
41
+
}
42
42
+
43
43
+
& div:has(input[name="title"]) {
44
44
+
display: grid;
45
45
+
inline-size: 100%;
46
46
+
grid-template-columns: 1fr auto;
47
47
+
gap: 10px;
48
48
+
49
49
+
& input {
50
50
+
grid-column: 1;
51
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
12
+
13
13
+
&::placeholder {
14
14
+
color: rgb(var(--color-black) / 0.5);
15
15
+
opacity: 1;
16
16
+
}
12
17
}
+10
src/lib/types.ts
···
4
4
platform: App.Platform;
5
5
};
6
6
7
7
+
export type UserEvent = AuthEvent & {
8
8
+
locals: App.Locals & {
9
9
+
user: NonNullable<App.Locals["user"]>;
10
10
+
};
11
11
+
};
12
12
+
7
13
export const isAuthEvent = (event: RequestEvent): event is AuthEvent => {
8
14
return event.platform?.env !== undefined;
9
15
};
16
16
+
17
17
+
export const isUserEvent = (event: RequestEvent): event is UserEvent => {
18
18
+
return isAuthEvent(event) && event.locals.user !== undefined;
19
19
+
};
+6
-13
src/routes/+page.server.ts
···
4
4
startSession,
5
5
updateSession,
6
6
} from "$lib/server/session";
7
7
-
import { isAuthEvent } from "$lib/types";
7
7
+
import { isAuthEvent, isUserEvent } from "$lib/types";
8
8
import { parseActorProfile } from "$lib/valibot";
9
9
-
import { Client } from "@atcute/client";
10
10
-
import { type Actions, fail, redirect } from "@sveltejs/kit";
9
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
37
-
if (isAuthEvent(event) === false) {
38
38
-
throw new Error();
39
39
-
}
40
40
-
if (event.locals.user === undefined) {
41
41
-
return;
36
36
+
if (isUserEvent(event) === false) {
37
37
+
error(401);
42
38
}
43
39
const { user } = event.locals;
44
40
try {
···
69
65
}
70
66
},
71
67
purge: async (event) => {
72
72
-
if (isAuthEvent(event) === false) {
73
73
-
throw new Error();
68
68
+
if (isUserEvent(event) === false) {
69
69
+
error(401);
74
70
}
75
71
const { user } = event.locals;
76
76
-
if (user === undefined) {
77
77
-
return;
78
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
1
-
import { isAuthEvent } from "$lib/types";
1
1
+
import { isUserEvent } from "$lib/types";
2
2
import { parseBookmark } from "$lib/valibot";
3
3
import * as TID from "@atcute/tid";
4
4
-
import { type Actions, fail } from "@sveltejs/kit";
4
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
15
-
if (isAuthEvent(event) === false) {
16
16
-
throw new Error();
15
15
+
if (isUserEvent(event) === false) {
16
16
+
error(401);
17
17
}
18
18
const { user } = event.locals;
19
19
-
if (user === undefined) {
20
20
-
return;
21
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
39
-
if (isAuthEvent(event) === false) {
40
40
-
throw new Error();
41
41
-
}
42
42
-
if (event.locals.user === undefined) {
43
43
-
return;
36
36
+
if (isUserEvent(event) === false) {
37
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
79
-
if (isAuthEvent(event) === false) {
80
80
-
throw new Error();
81
81
-
}
82
82
-
if (event.locals.user === undefined) {
83
83
-
return;
73
73
+
if (isUserEvent(event) === false) {
74
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
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
9
-
10
10
let editDialog: HTMLDialogElement | null = $state(null);
11
11
let createDialog: HTMLDialogElement | null = $state(null);
12
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
21
+
const onInputURL: EventHandler<Event, HTMLInputElement> = (ev) => {
22
22
+
const dialog = ev.currentTarget.closest("dialog");
23
23
+
if (dialog !== createDialog) return;
24
24
+
createURL = URL.parse(ev.currentTarget.value);
25
25
+
};
26
26
+
27
27
+
const onFetchTitle = async (ev: MouseEvent) => {
28
28
+
if (createURL === null) return;
29
29
+
if (createDialog === null) return;
30
30
+
const titleInput =
31
31
+
createDialog.querySelector<HTMLInputElement>('[name="title"]');
32
32
+
if (titleInput === null) return;
33
33
+
const formData = new FormData();
34
34
+
formData.set("url", createURL.href);
35
35
+
const button = ev.target as HTMLButtonElement;
36
36
+
button.disabled = true;
37
37
+
try {
38
38
+
const response = await fetch("/bookmarks/title", {
39
39
+
method: "POST",
40
40
+
body: formData,
41
41
+
});
42
42
+
const { title } = await response.json();
43
43
+
const template = document.createElement("template");
44
44
+
template.innerHTML = title;
45
45
+
titleInput.value = template.content.textContent;
46
46
+
} catch {
47
47
+
// Whatever...
48
48
+
} finally {
49
49
+
button.disabled = false;
50
50
+
}
51
51
+
};
52
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
70
-
<input type="url" id="url" name="url" maxlength="1280" {value} required />
103
103
+
<input
104
104
+
type="url"
105
105
+
id="url"
106
106
+
name="url"
107
107
+
maxlength="1280"
108
108
+
placeholder="https://..."
109
109
+
{value}
110
110
+
oninput={onInputURL}
111
111
+
required
112
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
97
-
{@render titleInput(
98
98
-
form?.action === "createBookmark" ? form?.data?.title.toString() : "",
99
99
-
)}
139
139
+
<div>
140
140
+
{@render titleInput(
141
141
+
form?.action === "createBookmark" ? form?.data?.title.toString() : "",
142
142
+
)}
143
143
+
<button
144
144
+
type="button"
145
145
+
onclick={onFetchTitle}
146
146
+
disabled={createURL === null}>Fetch</button
147
147
+
>
148
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
1
-
import { redirect } from "@sveltejs/kit";
1
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
13
+
if (
14
14
+
event.request.headers.get("Sec-Fetch-Site") !== "same-origin"
15
15
+
) {
16
16
+
error(401);
17
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
1
+
import { isUserEvent } from "$lib/types";
2
2
+
import { error, json } from "@sveltejs/kit";
3
3
+
import type { RequestHandler } from "./$types";
4
4
+
5
5
+
export const POST: RequestHandler = async (event) => {
6
6
+
if (
7
7
+
isUserEvent(event) === false ||
8
8
+
event.request.headers.get("Sec-Fetch-Site") !== "same-origin"
9
9
+
) {
10
10
+
error(401);
11
11
+
}
12
12
+
try {
13
13
+
const formData = await event.request.formData();
14
14
+
const url = new URL(String(formData.get("url")));
15
15
+
const origin = new URL(event.platform.env.ORIGIN);
16
16
+
if (url.host === origin.host) {
17
17
+
return json({ title: "Attic" });
18
18
+
}
19
19
+
const response = await event.fetch(url, {
20
20
+
signal: AbortSignal.timeout(5000),
21
21
+
});
22
22
+
if (
23
23
+
response.headers.get("Content-Type")?.startsWith("text/html") === false
24
24
+
) {
25
25
+
throw new Error();
26
26
+
}
27
27
+
const html = await response.text();
28
28
+
const match = html.match(/<title>(.+?)<\/title>/);
29
29
+
if (match === null) {
30
30
+
throw new Error();
31
31
+
}
32
32
+
const title = match[1].trim();
33
33
+
if (title.length < 1 || title.length > 2560) {
34
34
+
throw new Error();
35
35
+
}
36
36
+
return json({ title });
37
37
+
} catch {
38
38
+
return json({ title: "Untitled" });
39
39
+
}
40
40
+
};