+20
src/components/Button.svelte
+20
src/components/Button.svelte
···
1
+
<script lang="ts">
2
+
import type { Snippet } from "svelte";
3
+
4
+
interface Props {
5
+
children: Snippet;
6
+
}
7
+
8
+
let { children }: Props = $props();
9
+
</script>
10
+
11
+
<button class="button">
12
+
{@render children()}
13
+
</button>
14
+
15
+
<style>
16
+
.button {
17
+
border: none;
18
+
border-radius: var(--radius-md);
19
+
}
20
+
</style>
+38
src/lib/date.ts
+38
src/lib/date.ts
···
1
+
export function getRelativeTime(date: Date): string {
2
+
const now = new Date();
3
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
4
+
5
+
// future dates
6
+
if (diffInSeconds < 0) {
7
+
const absDiff = Math.abs(diffInSeconds);
8
+
9
+
if (absDiff < 60) return "in a few seconds";
10
+
if (absDiff < 3600) return `in ${Math.floor(absDiff / 60)} minutes`;
11
+
if (absDiff < 86400) return `in ${Math.floor(absDiff / 3600)} hours`;
12
+
if (absDiff < 2592000) return `in ${Math.floor(absDiff / 86400)} days`;
13
+
if (absDiff < 31536000) return `in ${Math.floor(absDiff / 2592000)} months`;
14
+
return `in ${Math.floor(absDiff / 31536000)} years`;
15
+
}
16
+
17
+
// past dates
18
+
if (diffInSeconds < 60) return "just now";
19
+
if (diffInSeconds < 3600) {
20
+
const minutes = Math.floor(diffInSeconds / 60);
21
+
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
22
+
}
23
+
if (diffInSeconds < 86400) {
24
+
const hours = Math.floor(diffInSeconds / 3600);
25
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
26
+
}
27
+
if (diffInSeconds < 2592000) {
28
+
const days = Math.floor(diffInSeconds / 86400);
29
+
return `${days} day${days === 1 ? "" : "s"} ago`;
30
+
}
31
+
if (diffInSeconds < 31536000) {
32
+
const months = Math.floor(diffInSeconds / 2592000);
33
+
return `${months} month${months === 1 ? "" : "s"} ago`;
34
+
}
35
+
36
+
const years = Math.floor(diffInSeconds / 31536000);
37
+
return `${years} year${years === 1 ? "" : "s"} ago`;
38
+
}
+16
-1
src/routes/+layout.svelte
+16
-1
src/routes/+layout.svelte
···
8
8
<link rel="icon" href={favicon} />
9
9
</svelte:head>
10
10
11
-
{@render children?.()}
11
+
<div class="app">
12
+
{@render children?.()}
13
+
</div>
12
14
13
15
<style>
14
16
:global {
15
17
@import "~/styles/reset.css";
16
18
@import "~/styles/theme.css";
19
+
}
20
+
21
+
:global(html) {
22
+
font-family: var(--font-body);
23
+
}
24
+
25
+
.app {
26
+
--app-max-width: 40rem;
27
+
--app-inline-padding: 24px;
28
+
position: relative;
29
+
max-width: var(--app-max-width);
30
+
margin: 0 auto;
31
+
padding: 0 var(--app-inline-padding);
17
32
}
18
33
</style>
+23
-2
src/routes/~/+layout.svelte
+23
-2
src/routes/~/+layout.svelte
···
19
19
20
20
<header class="header">
21
21
<h1><a href="/~/">athost</a></h1>
22
-
<button popovertarget="menu">menu</button>
22
+
<button class="menubutton" popovertarget="menu">
23
+
<img class="avatar" src={data.profile.avatar} alt="" width={24} />
24
+
<span>@{data.profile.displayName}</span>
25
+
</button>
23
26
<div id="menu" class="menu" popover>
24
-
<p>hello, {data.name}</p>
25
27
<button onclick={logOut}>log out</button>
26
28
</div>
27
29
</header>
···
32
34
33
35
<style>
34
36
.header {
37
+
position: relative;
35
38
display: flex;
36
39
justify-content: space-between;
37
40
align-items: center;
41
+
}
42
+
43
+
.menubutton {
44
+
display: flex;
45
+
align-items: center;
46
+
}
47
+
48
+
.menu {
49
+
&:popover-open {
50
+
position: absolute;
51
+
inset: unset;
52
+
right: calc(50% - var(--app-max-width) / 2 + var(--app-inline-padding));
53
+
margin-top: 4rem;
54
+
}
55
+
}
56
+
57
+
.avatar {
58
+
border-radius: 50%;
38
59
}
39
60
</style>
+2
-2
src/routes/~/+layout.ts
+2
-2
src/routes/~/+layout.ts
···
14
14
15
15
const atp = client(session);
16
16
17
-
const { displayName } = await atp.getProfile(did);
17
+
const profile = await atp.getProfile(did);
18
18
19
-
return { session, pds: session.info.aud, did, name: displayName };
19
+
return { session, pds: session.info.aud, did, profile };
20
20
} catch (e) {
21
21
console.error(e);
22
22
redirect(303, "/");
+2
-1
src/routes/~/+page.svelte
+2
-1
src/routes/~/+page.svelte
···
2
2
import { invalidate } from "$app/navigation";
3
3
4
4
import { client } from "~/lib/oauth";
5
+
import Button from "~/components/Button.svelte";
5
6
6
7
let { data } = $props();
7
8
···
23
24
24
25
<form onsubmit={createWebsite}>
25
26
<input type="text" name="rkey" minlength={1} maxlength={512} pattern="[A-Za-z0-9.\-]+" />
26
-
<button>create</button>
27
+
<Button>create</Button>
27
28
</form>
28
29
29
30
<ul>
+78
-46
src/routes/~/sites/[name]/+page.svelte
+78
-46
src/routes/~/sites/[name]/+page.svelte
···
1
1
<script lang="ts">
2
2
import { goto, invalidate } from "$app/navigation";
3
+
import { getRelativeTime } from "~/lib/date";
3
4
4
5
import { client } from "~/lib/oauth";
5
6
···
37
38
}
38
39
</script>
39
40
40
-
<header class="header">
41
-
<h2>{params.name}</h2>
42
-
</header>
41
+
<div class="detail">
42
+
<header class="header">
43
+
<h2>{params.name}</h2>
44
+
</header>
43
45
44
-
<details>
45
-
<summary>
46
-
<span>{data.record.value.description || data.record.cid}</span>
47
-
<time>{data.record.value.createdAt}</time>
48
-
</summary>
49
-
<ul>
50
-
{#each Object.entries(data.record.value.assets) as [path, file]}
51
-
<li>
52
-
<span>{path}</span>
53
-
<a
54
-
target="_blank"
55
-
href="{data.pds}xrpc/com.atproto.sync.getBlob?did={data.did}&cid={file.ref.$link}">open</a
56
-
>
57
-
</li>
58
-
{/each}
59
-
</ul>
60
-
</details>
46
+
<details class="deploy">
47
+
<summary class="summary">
48
+
<span>{data.record.value.description || data.record.cid}</span>
49
+
<time datetime={data.record.value.createdAt}>
50
+
{getRelativeTime(new Date(data.record.value.createdAt))}
51
+
</time>
52
+
</summary>
53
+
<ul>
54
+
{#each Object.entries(data.record.value.assets) as [path, file]}
55
+
<li>
56
+
<span>{path}</span>
57
+
<a
58
+
target="_blank"
59
+
href="{data.pds}xrpc/com.atproto.sync.getBlob?did={data.did}&cid={file.ref.$link}"
60
+
>open</a
61
+
>
62
+
</li>
63
+
{/each}
64
+
</ul>
65
+
</details>
61
66
62
-
<h3>upload</h3>
63
-
<form onsubmit={deployBundle}>
64
-
<input type="hidden" name="rkey" value={params.name} />
65
-
<label>
66
-
<span>description</span>
67
-
<input name="description" />
68
-
</label>
69
-
<input type="file" name="files" webkitdirectory />
70
-
<button>upload</button>
71
-
</form>
67
+
<section>
68
+
<h3>Upload</h3>
69
+
<form onsubmit={deployBundle}>
70
+
<input type="hidden" name="rkey" value={params.name} />
71
+
<label>
72
+
<span>description</span>
73
+
<input name="description" />
74
+
</label>
75
+
<input type="file" name="files" webkitdirectory />
76
+
<button>upload</button>
77
+
</form>
78
+
</section>
72
79
73
-
<h3>settings</h3>
74
-
<form>
75
-
<label>
76
-
<span>not found</span>
77
-
<input name="notfound" />
78
-
</label>
79
-
<button>save</button>
80
-
</form>
80
+
<section>
81
+
<h3>Settings</h3>
82
+
<form>
83
+
<label>
84
+
<span>not found</span>
85
+
<input name="notfound" />
86
+
</label>
87
+
<button>save</button>
88
+
</form>
89
+
</section>
81
90
82
-
<h3>danger zone</h3>
83
-
<form onsubmit={deleteBundle}>
84
-
<label>
85
-
<span>site name</span>
86
-
<input name="rkey" pattern={params.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} />
87
-
</label>
88
-
<button>delete</button>
89
-
</form>
91
+
<section>
92
+
<h3>Danger Zone</h3>
93
+
<form onsubmit={deleteBundle}>
94
+
<label>
95
+
<span>site name</span>
96
+
<input name="rkey" pattern={params.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} />
97
+
</label>
98
+
<button>delete</button>
99
+
</form>
100
+
</section>
101
+
</div>
102
+
103
+
<style>
104
+
.detail {
105
+
display: flex;
106
+
flex-direction: column;
107
+
gap: var(--size-10);
108
+
}
109
+
110
+
.deploy {
111
+
border: var(--size-border) solid black;
112
+
border-radius: var(--radius-md);
113
+
}
114
+
115
+
.summary {
116
+
display: flex;
117
+
justify-content: space-between;
118
+
align-items: center;
119
+
padding: var(--size-3) var(--size-4);
120
+
}
121
+
</style>
src/styles/bricolage-grotesque-variable.woff2
src/styles/bricolage-grotesque-variable.woff2
This is a binary file and will not be displayed.
src/styles/supreme-variable.woff2
src/styles/supreme-variable.woff2
This is a binary file and will not be displayed.
+37
-3
src/styles/theme.css
+37
-3
src/styles/theme.css
···
1
1
@font-face {
2
-
font-family: "Supreme";
3
-
src: url("./supreme-variable.woff2") format("woff2");
2
+
font-family: "Bricolage Grotesque";
3
+
src: url("./bricolage-grotesque-variable.woff2") format("woff2");
4
4
font-weight: 100 1000;
5
5
}
6
6
7
7
:root {
8
-
font-family: "Supreme", sans-serif;
8
+
/* fonts */
9
+
--font-body: "Bricolage Grotesque", sans-serif;
10
+
11
+
/* sizes */
12
+
--size-border: 1px;
13
+
--size-outline: 3px;
14
+
--size-1: 2px;
15
+
--size-2: 4px;
16
+
--size-3: 8px;
17
+
--size-4: 12px;
18
+
--size-5: 16px;
19
+
--size-6: 20px;
20
+
--size-7: 24px;
21
+
--size-8: 28px;
22
+
--size-9: 32px;
23
+
--size-10: 48px;
24
+
25
+
/* corner radii */
26
+
--radius-sm: 2px;
27
+
--radius-md: 6px;
28
+
--radius-lg: 8px;
29
+
--radius-xl: 12px;
30
+
}
31
+
32
+
@supports (corner-shape: squircle) {
33
+
* {
34
+
corner-shape: squircle;
35
+
}
36
+
37
+
:root {
38
+
--radius-sm: 4px;
39
+
--radius-md: 12px;
40
+
--radius-lg: 16px;
41
+
--radius-xl: 24px;
42
+
}
9
43
}