tangled
alpha
login
or
join now
dbushell.com
/
attic.social
11
fork
atom
Attic is a cozy space with lofty ambitions.
attic.social
11
fork
atom
overview
issues
pulls
pipelines
bookmark create dialog and delete action
dbushell.com
1 week ago
a5c2d4b4
7bc7a625
verified
This commit was signed with the committer's
known signature
.
dbushell.com
SSH Key Fingerprint:
SHA256:Sj5AfJ6VbC0PEnnQD2kGGEiGFwHdFBS/ypN5oifzzFI=
+257
-49
12 changed files
expand all
collapse all
unified
split
src
css
base
global.css
properties.css
components
bookmark.css
button.css
form.css
input.css
main.css
utility
flex.css
lib
valibot.ts
routes
+page.svelte
bookmarks
[did=did]
+page.server.ts
+page.svelte
+22
-2
src/css/base/global.css
···
24
24
[full-start]
25
25
1fr
26
26
[margin-start]
27
27
-
minmax(20px, min(5vi, 100px))
27
27
+
var(--app-margin)
28
28
[main-start]
29
29
minmax(260px, calc((900 / 16) * 1rem))
30
30
[main-end]
31
31
-
minmax(20px, min(5vi, 100px))
31
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
92
+
93
93
+
dialog {
94
94
+
inline-size: min(min(100%, 900px), calc(100vi - (2 * var(--app-margin))));
95
95
+
96
96
+
& [command="close"] {
97
97
+
inset-block-start: 20px;
98
98
+
inset-inline-end: 20px;
99
99
+
position: absolute;
100
100
+
z-index: 1;
101
101
+
102
102
+
&::before {
103
103
+
content: "×" / "";
104
104
+
}
105
105
+
}
106
106
+
107
107
+
&::backdrop {
108
108
+
background: rgb(var(--color-brown) / 0.5);
109
109
+
backdrop-filter: blur(5px);
110
110
+
}
111
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
19
+
20
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
54
+
55
55
+
& > form {
56
56
+
display: contents;
57
57
+
z-index: 1;
58
58
+
59
59
+
& > button {
60
60
+
z-index: 1;
61
61
+
}
62
62
+
}
54
63
}
+16
-4
src/css/components/button.css
···
1
1
button[type] {
2
2
-
/*background: white;*/
3
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
6
-
block-size: calc(var(--font-size) + 30px);
4
4
+
block-size: calc(var(--font-size-button) + 30px);
5
5
+
color: rgb(var(--color-black));
7
6
font-family: var(--font-family-2);
8
8
-
font-size: var(--font-size);
7
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
19
+
}
20
20
+
21
21
+
&[data-danger] {
22
22
+
&:not(:hover) {
23
23
+
border-image-source: url("/images/button-danger.svg");
24
24
+
color: rgb(var(--color-red));
25
25
+
}
26
26
+
}
27
27
+
28
28
+
:where(.Bookmark) & {
29
29
+
--font-size-button: var(--font-size-3);
30
30
+
border-width: 10px;
31
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
84
-
85
85
-
&[action*="purge"] {
86
86
-
& button {
87
87
-
&:not(:hover) {
88
88
-
border-image-source: url("/images/button-danger.svg");
89
89
-
color: rgb(var(--color-red));
90
90
-
}
91
91
-
}
92
92
-
}
93
84
}
+1
-1
src/css/components/input.css
···
1
1
input {
2
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
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
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
36
+
z-index: 999;
35
37
36
38
@supports not (inset: anchor(start)) {
37
39
display: none;
+84
src/css/utility/flex.css
···
1
1
+
.flex {
2
2
+
display: flex;
3
3
+
gap: 10px;
4
4
+
}
5
5
+
6
6
+
.flex-wrap {
7
7
+
flex-wrap: wrap;
8
8
+
}
9
9
+
10
10
+
.ac-around {
11
11
+
align-content: space-around;
12
12
+
}
13
13
+
.ac-between {
14
14
+
align-content: space-between;
15
15
+
}
16
16
+
.ac-evenly {
17
17
+
align-content: space-evenly;
18
18
+
}
19
19
+
.ac-center {
20
20
+
align-content: center;
21
21
+
}
22
22
+
.ac-end {
23
23
+
align-content: end;
24
24
+
}
25
25
+
.ac-start {
26
26
+
align-content: start;
27
27
+
}
28
28
+
.ac-stretch {
29
29
+
align-content: stretch;
30
30
+
}
31
31
+
32
32
+
.ai-baseline {
33
33
+
align-items: baseline;
34
34
+
}
35
35
+
.ai-center {
36
36
+
align-items: center;
37
37
+
}
38
38
+
.ai-end {
39
39
+
align-items: end;
40
40
+
}
41
41
+
.ai-start {
42
42
+
align-items: start;
43
43
+
}
44
44
+
.ai-stretch {
45
45
+
align-items: stretch;
46
46
+
}
47
47
+
48
48
+
.jc-around {
49
49
+
justify-content: space-around;
50
50
+
}
51
51
+
.jc-between {
52
52
+
justify-content: space-between;
53
53
+
}
54
54
+
.jc-evenly {
55
55
+
justify-content: space-evenly;
56
56
+
}
57
57
+
.jc-center {
58
58
+
justify-content: center;
59
59
+
}
60
60
+
.jc-end {
61
61
+
justify-content: end;
62
62
+
}
63
63
+
.jc-start {
64
64
+
justify-content: start;
65
65
+
}
66
66
+
.jc-stretch {
67
67
+
justify-content: stretch;
68
68
+
}
69
69
+
70
70
+
.ji-baseline {
71
71
+
justify-items: baseline;
72
72
+
}
73
73
+
.ji-center {
74
74
+
justify-items: center;
75
75
+
}
76
76
+
.ji-end {
77
77
+
justify-items: end;
78
78
+
}
79
79
+
.ji-start {
80
80
+
justify-items: start;
81
81
+
}
82
82
+
.ji-stretch {
83
83
+
justify-items: stretch;
84
84
+
}
+2
src/lib/valibot.ts
···
56
56
title: v.pipe(
57
57
v.string(),
58
58
v.trim(),
59
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
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
179
-
<button type="submit">Confirm</button>
179
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
8
+
delete: async (event) => {
9
9
+
if (isAuthEvent(event) === false) {
10
10
+
throw new Error();
11
11
+
}
12
12
+
const { user } = event.locals;
13
13
+
if (user === undefined) {
14
14
+
return;
15
15
+
}
16
16
+
const formData = await event.request.formData();
17
17
+
const rpc = new Client({ handler: user.session });
18
18
+
const result = await rpc.post("com.atproto.repo.deleteRecord", {
19
19
+
input: {
20
20
+
repo: user.did,
21
21
+
collection: "social.attic.bookmark.entity",
22
22
+
rkey: String(formData.get("rkey")),
23
23
+
},
24
24
+
});
25
25
+
if (result.ok === false) {
26
26
+
return fail(400, {
27
27
+
action: "delete",
28
28
+
error: "Failed to delete bookmark.",
29
29
+
});
30
30
+
}
31
31
+
return { success: true };
32
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
19
-
// @ts-ignore normalize url
20
20
-
data.url = URL.parse(formData.get("url"))?.href;
21
44
try {
45
45
+
// @ts-ignore normalize url
46
46
+
const parsed = URL.parse(formData.get("url"));
47
47
+
// if (parsed === null) {
48
48
+
// throw new Error("Invalid URL");
49
49
+
// }
50
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
37
-
console.log(err);
66
66
+
let error = "Failed to create bookmark.";
67
67
+
if (err instanceof Error) {
68
68
+
error = err.message;
69
69
+
}
38
70
return fail(400, {
39
39
-
data,
71
71
+
data: Object.fromEntries(formData),
40
72
action: "create",
41
41
-
error: "Failed to create bookmark.",
73
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
8
+
let createDialog: HTMLDialogElement | null = $state(null);
9
9
+
10
10
+
$effect(() => {
11
11
+
if (form?.action === "create" && "error" in form) {
12
12
+
if (createDialog?.open === false) {
13
13
+
createDialog?.showModal();
14
14
+
}
15
15
+
}
16
16
+
});
17
17
+
18
18
+
// [TODO] better error dialog?
19
19
+
$effect(() => {
20
20
+
if (form?.action === "delete") {
21
21
+
if (form.error) alert(form.error);
22
22
+
}
23
23
+
});
24
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
25
-
<form method="POST" action="?/create">
26
26
-
<h2>Create bookmark</h2>
27
27
-
<p>Please remember: all atproto data is public.</p>
28
28
-
{#if form?.action === "create" && form?.error}
29
29
-
<p class="error">{form.error}</p>
30
30
-
{/if}
31
31
-
<label for="url">URL</label>
32
32
-
<input
33
33
-
type="url"
34
34
-
id="url"
35
35
-
name="url"
36
36
-
maxlength="1280"
37
37
-
value={form?.action === "create" ? form.data.url : ""}
38
38
-
required
39
39
-
/>
40
40
-
<label for="title">Title</label>
41
41
-
<input
42
42
-
type="text"
43
43
-
id="title"
44
44
-
name="title"
45
45
-
maxlength="1280"
46
46
-
value={form?.action === "create" ? form.data.title : ""}
47
47
-
required
48
48
-
/>
49
49
-
<button type="submit">Create</button>
50
50
-
</form>
42
42
+
<dialog id="create" bind:this={createDialog}>
43
43
+
<button
44
44
+
type="button"
45
45
+
commandfor="create"
46
46
+
command="close"
47
47
+
onclick={(ev) => {
48
48
+
ev.preventDefault();
49
49
+
createDialog?.close();
50
50
+
}}
51
51
+
>
52
52
+
<span class="visually-hidden">close</span>
53
53
+
</button>
54
54
+
<form method="POST" action="?/create">
55
55
+
<h2>Create bookmark</h2>
56
56
+
<p>Please remember: all atproto data is public.</p>
57
57
+
{#if form?.action === "create" && form?.error}
58
58
+
<p class="error">{form.error}</p>
59
59
+
{/if}
60
60
+
<label for="url">URL</label>
61
61
+
<input
62
62
+
type="url"
63
63
+
id="url"
64
64
+
name="url"
65
65
+
maxlength="1280"
66
66
+
value={form?.action === "create" ? form.data.url : ""}
67
67
+
required
68
68
+
/>
69
69
+
<label for="title">Title</label>
70
70
+
<input
71
71
+
type="text"
72
72
+
id="title"
73
73
+
name="title"
74
74
+
maxlength="1280"
75
75
+
value={form?.action === "create" ? form.data.title : ""}
76
76
+
required
77
77
+
/>
78
78
+
<button type="submit">Create</button>
79
79
+
</form>
80
80
+
</dialog>
51
81
{/if}
52
82
53
83
{#if data.bookmarks.length}
54
84
<div class="Bookmarks">
55
55
-
<h2>Bookmarks</h2>
85
85
+
<div class="flex flex-wrap ai-center jc-between">
86
86
+
<h2>Bookmarks</h2>
87
87
+
{#if isSelf}
88
88
+
<button type="button" onclick={() => createDialog?.showModal()}>
89
89
+
New
90
90
+
</button>
91
91
+
{/if}
92
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
104
+
{#if isSelf}
105
105
+
<form method="POST" action="?/delete">
106
106
+
<input
107
107
+
type="hidden"
108
108
+
name="rkey"
109
109
+
value={entry.uri.split("/").at(-1)}
110
110
+
/>
111
111
+
<button
112
112
+
data-danger
113
113
+
type="submit"
114
114
+
onclick={(ev) => {
115
115
+
if (confirm("Are you sure?")) return;
116
116
+
else ev.preventDefault();
117
117
+
}}>Delete</button
118
118
+
>
119
119
+
</form>
120
120
+
{/if}
67
121
</article>
68
122
{/each}
69
123
</div>