tangled
alpha
login
or
join now
hexmani.ac
/
clippr
12
fork
atom
social bookmarking for atproto
12
fork
atom
overview
issues
1
pulls
pipelines
[frontend] Build out basic profile pages
hexmani.ac
7 months ago
65aa84c9
eedf4e54
verified
This commit was signed with the committer's
known signature
.
hexmani.ac
SSH Key Fingerprint:
SHA256:tV3v2UX4P3x12jjh+mHVzpRQ4ZhNBCHoFwqRiYzzTcM=
+296
-102
14 changed files
expand all
collapse all
unified
split
frontend
package.json
pnpm-lock.yaml
src
components
header.tsx
pageLocation.tsx
profile.tsx
profileEditor.tsx
profileWidget.tsx
index.tsx
styles
index.css
utils
profile.ts
views
home.tsx
notFound.tsx
profile.tsx
settings.tsx
+1
frontend/package.json
···
21
21
},
22
22
"dependencies": {
23
23
"@atcute/client": "^4.0.3",
24
24
+
"@atcute/identity-resolver": "^1.1.3",
24
25
"@atcute/lexicons": "^1.1.0",
25
26
"@atcute/oauth-browser-client": "^1.0.26",
26
27
"@mary/exif-rm": "jsr:^0.2.2",
+22
frontend/pnpm-lock.yaml
···
11
11
'@atcute/client':
12
12
specifier: ^4.0.3
13
13
version: 4.0.3
14
14
+
'@atcute/identity-resolver':
15
15
+
specifier: ^1.1.3
16
16
+
version: 1.1.3(@atcute/identity@1.0.3)
14
17
'@atcute/lexicons':
15
18
specifier: ^1.1.0
16
19
version: 1.1.0
···
55
58
'@atcute/client@4.0.3':
56
59
resolution: {integrity: sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==}
57
60
61
61
+
'@atcute/identity-resolver@1.1.3':
62
62
+
resolution: {integrity: sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==}
63
63
+
peerDependencies:
64
64
+
'@atcute/identity': ^1.0.0
65
65
+
58
66
'@atcute/identity@1.0.3':
59
67
resolution: {integrity: sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==}
60
68
···
69
77
70
78
'@atcute/uint8array@1.0.3':
71
79
resolution: {integrity: sha512-M/K+ihiVW8Pl2PFLzaC4E3l4JaZ1IH05Q0AbPWUC4cVHnd/gZ/1kAF5ngdtGvJeDMirHZ2VAy7OmAsPwR/2nlA==}
80
80
+
81
81
+
'@atcute/util-fetch@1.0.1':
82
82
+
resolution: {integrity: sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==}
72
83
73
84
'@babel/code-frame@7.27.1':
74
85
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
···
794
805
'@atcute/identity': 1.0.3
795
806
'@atcute/lexicons': 1.1.0
796
807
808
808
+
'@atcute/identity-resolver@1.1.3(@atcute/identity@1.0.3)':
809
809
+
dependencies:
810
810
+
'@atcute/identity': 1.0.3
811
811
+
'@atcute/lexicons': 1.1.0
812
812
+
'@atcute/util-fetch': 1.0.1
813
813
+
'@badrap/valita': 0.4.6
814
814
+
797
815
'@atcute/identity@1.0.3':
798
816
dependencies:
799
817
'@atcute/lexicons': 1.1.0
···
817
835
nanoid: 5.1.5
818
836
819
837
'@atcute/uint8array@1.0.3': {}
838
838
+
839
839
+
'@atcute/util-fetch@1.0.1':
840
840
+
dependencies:
841
841
+
'@badrap/valita': 0.4.6
820
842
821
843
'@babel/code-frame@7.27.1':
822
844
dependencies:
+9
-5
frontend/src/components/header.tsx
···
4
4
* SPDX-License-Identifier: AGPL-3.0-only
5
5
*/
6
6
7
7
-
import { killSession, loginState } from "./loginForm.tsx";
7
7
+
import { agent, loginState } from "./loginForm.tsx";
8
8
+
import { A } from "@solidjs/router";
8
9
9
10
const Header = () => {
10
11
return (
11
12
<header>
12
13
<div id="header-left">
13
13
-
<a href={loginState() ? "/home" : "/"}>
14
14
+
<A href={loginState() ? "/home" : "/"}>
14
15
<p class="silent-link">clippr (beta)</p>
15
15
-
</a>
16
16
+
</A>
16
17
</div>
17
18
<div id="header-right">
18
19
<nav>
19
20
{loginState() ? (
20
20
-
<a onclick={killSession}>logout</a>
21
21
+
<>
22
22
+
<A href={`/profile/${agent.sub}`}>profile</A>
23
23
+
<A href="/settings">settings</A>
24
24
+
</>
21
25
) : (
22
22
-
<a href="/login">login</a>
26
26
+
<A href="/login">login</A>
23
27
)}
24
28
</nav>
25
29
</div>
+13
-3
frontend/src/components/pageLocation.tsx
···
6
6
7
7
import { splitProps } from "solid-js";
8
8
9
9
+
// How to define the path because I forgot how to and struggled for half an hour:
10
10
+
// ```
11
11
+
// [
12
12
+
// {
13
13
+
// name: "home",
14
14
+
// link: "/"
15
15
+
// }
16
16
+
// ]
17
17
+
// ```
9
18
type PathItem = {
10
19
name: string;
11
20
link: string;
···
13
22
14
23
const PageLocation = (props: any) => {
15
24
const [local, others] = splitProps(props, ["lastLocation", "path"]);
25
25
+
const path = local.path;
16
26
17
17
-
if (typeof local.path === "undefined") {
27
27
+
if (typeof path === "undefined") {
18
28
return (
19
29
<div id="page-location">
20
30
<p id="page-location-last">{local.lastLocation || "blank"}</p>
···
24
34
25
35
return (
26
36
<div id="page-location">
27
27
-
{local.path.map((item: PathItem) => {
37
37
+
{path.map((item: PathItem) => {
28
38
return (
29
39
<>
30
40
<p class="page-location-parent" {...others}>
31
31
-
<a href={item.link}>{item.name}</a>
41
41
+
<a href={item.link || ""}>{item.name}</a>
32
42
</p>
33
43
<p>→</p>
34
44
</>
-68
frontend/src/components/profile.tsx
···
1
1
-
/*
2
2
-
* clippr: a social bookmarking service for the AT Protocol
3
3
-
* Copyright (c) 2025 clippr contributors.
4
4
-
* SPDX-License-Identifier: AGPL-3.0-only
5
5
-
*/
6
6
-
7
7
-
import { createResource, createSignal, Match, Show, Switch } from "solid-js";
8
8
-
import { agent } from "./loginForm.tsx";
9
9
-
import { ErrorResponse, ProfileViewQuery } from "../types.ts";
10
10
-
11
11
-
const Profile = () => {
12
12
-
const fetchProfile = async (actor: any): Promise<ProfileViewQuery> => {
13
13
-
const response: Response = await fetch(
14
14
-
`${import.meta.env.VITE_CLIPPR_APPVIEW}/xrpc/social.clippr.actor.getProfile?actor=${actor}`,
15
15
-
);
16
16
-
17
17
-
if (response.status !== 200) {
18
18
-
if (response.status === 400) {
19
19
-
const json: ErrorResponse = await response.json();
20
20
-
console.log(json);
21
21
-
throw new Error(json.message);
22
22
-
} else {
23
23
-
throw new Error(response.statusText);
24
24
-
}
25
25
-
}
26
26
-
27
27
-
return response.json();
28
28
-
};
29
29
-
30
30
-
const [actor, setActor] = createSignal();
31
31
-
setActor(agent.session.info.sub);
32
32
-
33
33
-
const [profile] = createResource(actor, fetchProfile);
34
34
-
35
35
-
return (
36
36
-
<div>
37
37
-
<h2>profile</h2>
38
38
-
<Show when={profile.loading}>
39
39
-
<p>loading...</p>
40
40
-
</Show>
41
41
-
<Switch>
42
42
-
<Match when={profile.error}>
43
43
-
<p>error: {profile.error.message}</p>
44
44
-
</Match>
45
45
-
<Match when={profile()}>
46
46
-
<div id="profile-view">
47
47
-
<img
48
48
-
src={profile()?.avatar}
49
49
-
class="profile-picture"
50
50
-
alt="The user's avatar."
51
51
-
/>
52
52
-
<div>
53
53
-
<p>
54
54
-
<b>{profile()?.displayName}</b>
55
55
-
</p>
56
56
-
<p title={profile()?.did}>
57
57
-
{profile()?.handle.replace("at://", "@")}
58
58
-
</p>
59
59
-
<p>{profile()?.description}</p>
60
60
-
</div>
61
61
-
</div>
62
62
-
</Match>
63
63
-
</Switch>
64
64
-
</div>
65
65
-
);
66
66
-
};
67
67
-
68
68
-
export { Profile };
+18
-17
frontend/src/components/profileEditor.tsx
···
4
4
* SPDX-License-Identifier: AGPL-3.0-only
5
5
*/
6
6
7
7
-
import { createSignal } from "solid-js";
7
7
+
import { createResource, createSignal } from "solid-js";
8
8
import { Client, ClientResponse } from "@atcute/client";
9
9
import { remove } from "@mary/exif-rm";
10
10
import { agent } from "./loginForm.tsx";
11
11
+
import { fetchProfile } from "../utils/profile.ts";
11
12
12
13
const ProfileEditor = () => {
14
14
+
const [actor, setActor] = createSignal();
15
15
+
const [avatarPreview, setAvatarPreview] = createSignal("");
13
16
const [notice, setNotice] = createSignal("");
14
17
let formRef: HTMLFormElement = document.createElement("form");
15
18
19
19
+
setActor(agent.session.info.sub);
20
20
+
const [profile] = createResource(actor, fetchProfile);
21
21
+
16
22
const uploadBlob = async () => {
17
23
setNotice("");
18
18
-
console.log("starting upload...");
24
24
+
console.log("starting avatar upload...");
19
25
let blob: Blob;
20
26
21
27
const file = (document.getElementById("avatar") as HTMLInputElement)
···
46
52
47
53
const rpc = new Client({ handler: agent! });
48
54
setNotice("uploading avatar...");
49
49
-
// @ts-ignore
50
50
-
const uploadRes: ClientResponse<any, any> = await rpc.post("com.atproto.repo.uploadBlob",
55
55
+
const uploadRes: ClientResponse<any, any> = await rpc.post(
56
56
+
// @ts-ignore
57
57
+
"com.atproto.repo.uploadBlob",
51
58
{
52
59
input: blob,
53
60
},
···
57
64
setNotice(uploadRes.data.error);
58
65
return;
59
66
}
60
60
-
setNotice("avatar has been uploaded!");
67
67
+
setNotice("");
68
68
+
setAvatarPreview(URL.createObjectURL(blob));
61
69
62
70
console.log(uploadRes.data);
63
71
localStorage.setItem("avatar", JSON.stringify(uploadRes.data.blob));
···
74
82
}
75
83
76
84
const displayName = formData.get("displayName") as string;
77
77
-
if (
78
78
-
displayName === null ||
79
79
-
displayName === ""
80
80
-
) {
85
85
+
if (displayName === null || displayName === "") {
81
86
setNotice("error: display name is missing");
82
87
return;
83
88
}
···
88
93
}
89
94
90
95
let description = formData.get("description") as string;
91
91
-
if (
92
92
-
description === null ||
93
93
-
description === ""
94
94
-
) {
96
96
+
if (description === null || description === "") {
95
97
description = "This user does not have a bio.";
96
98
}
97
99
···
129
131
130
132
setNotice("profile changed!");
131
133
localStorage.removeItem("avatar");
132
132
-
setTimeout(() => {
133
133
-
window.location.reload();
134
134
-
}, 1000);
135
134
};
136
135
137
136
return (
138
137
<div>
139
139
-
<h2>profile editor</h2>
140
138
<form ref={formRef}>
141
139
<label for="avatar" class="file-upload">
142
140
upload avatar
···
148
146
accept=".jpg,.jpeg,.png,image/jpeg,image/png"
149
147
onChange={() => uploadBlob()}
150
148
/>
149
149
+
<img class="profile-picture" src={avatarPreview()} alt="The user's uploaded avatar." hidden={avatarPreview() === ""} />
151
150
<label for="displayName">display name</label>
152
151
<input
153
152
type="text"
···
155
154
id="displayName"
156
155
maxLength="64"
157
156
placeholder="Alice"
157
157
+
value={profile()?.displayName || ""}
158
158
/>
159
159
<label for="description">bio</label>
160
160
<textarea
···
162
162
id="description"
163
163
maxLength="500"
164
164
placeholder="describe yourself..."
165
165
+
value={profile()?.description || ""}
165
166
></textarea>
166
167
<button
167
168
type="submit"
+60
frontend/src/components/profileWidget.tsx
···
1
1
+
/*
2
2
+
* clippr: a social bookmarking service for the AT Protocol
3
3
+
* Copyright (c) 2025 clippr contributors.
4
4
+
* SPDX-License-Identifier: AGPL-3.0-only
5
5
+
*/
6
6
+
7
7
+
import {
8
8
+
createResource,
9
9
+
Match,
10
10
+
Show,
11
11
+
splitProps,
12
12
+
Switch,
13
13
+
} from "solid-js";
14
14
+
import { agent } from "./loginForm.tsx";
15
15
+
import { fetchProfile } from "../utils/profile.ts";
16
16
+
17
17
+
interface ProfileProps {
18
18
+
actor?: string;
19
19
+
}
20
20
+
21
21
+
const ProfileWidget = (props: ProfileProps) => {
22
22
+
const [local] = splitProps(props, ["actor"]);
23
23
+
const actor = () => local.actor ?? agent.session.info.sub;
24
24
+
25
25
+
26
26
+
const [profile] = createResource(actor, fetchProfile);
27
27
+
28
28
+
return (
29
29
+
<div>
30
30
+
<Show when={profile.loading}>
31
31
+
<p>loading...</p>
32
32
+
</Show>
33
33
+
<Switch>
34
34
+
<Match when={profile.error}>
35
35
+
<p>error: {profile.error.message}</p>
36
36
+
</Match>
37
37
+
<Match when={profile()}>
38
38
+
<div id="profile-view">
39
39
+
<img
40
40
+
src={profile()?.avatar}
41
41
+
class="profile-picture"
42
42
+
alt="The user's avatar."
43
43
+
/>
44
44
+
<div>
45
45
+
<p>
46
46
+
<b>{profile()?.displayName}</b>
47
47
+
</p>
48
48
+
<p title={profile()?.did}>
49
49
+
{profile()?.handle.replace("at://", "@")}
50
50
+
</p>
51
51
+
<p>{profile()?.description}</p>
52
52
+
</div>
53
53
+
</div>
54
54
+
</Match>
55
55
+
</Switch>
56
56
+
</div>
57
57
+
);
58
58
+
};
59
59
+
60
60
+
export { ProfileWidget };
+4
frontend/src/index.tsx
···
13
13
import { NotFound } from "./views/notFound.tsx";
14
14
import { Home } from "./views/home.tsx";
15
15
import "solid-devtools";
16
16
+
import { Profile } from "./views/profile.tsx";
17
17
+
import { Settings } from "./views/settings.tsx";
16
18
17
19
render(
18
20
() => (
···
20
22
<Route path="/" component={LandingPage} />
21
23
<Route path="/login" component={Login} />
22
24
<Route path="/home" component={Home} />
25
25
+
<Route path={["/profile/:id", "/profile"]} component={Profile} />
26
26
+
<Route path="/settings" component={Settings} />
23
27
<Route path="*paramName" component={NotFound} />
24
28
</Router>
25
29
),
+1
-2
frontend/src/styles/index.css
···
64
64
margin: 0;
65
65
}
66
66
67
67
-
margin: 1rem 0 2rem 0;
67
67
+
margin: 1rem 0 1rem 0;
68
68
}
69
69
70
70
#page-location {
71
71
display: flex;
72
72
align-items: start;
73
73
-
width: 100%;
74
73
gap: 0.5rem;
75
74
padding: 0 0 0 0.5rem;
76
75
border: 1px solid var(--fg);
+83
frontend/src/utils/profile.ts
···
1
1
+
/*
2
2
+
* clippr: a social bookmarking service for the AT Protocol
3
3
+
* Copyright (c) 2025 clippr contributors.
4
4
+
* SPDX-License-Identifier: AGPL-3.0-only
5
5
+
*/
6
6
+
7
7
+
import { ErrorResponse, ProfileViewQuery } from "../types.ts";
8
8
+
import {
9
9
+
CompositeDidDocumentResolver,
10
10
+
DocumentNotFoundError,
11
11
+
FailedDocumentResolutionError,
12
12
+
HandleResolutionError,
13
13
+
ImproperDidError,
14
14
+
PlcDidDocumentResolver,
15
15
+
UnsupportedDidMethodError,
16
16
+
WebDidDocumentResolver,
17
17
+
} from "@atcute/identity-resolver";
18
18
+
19
19
+
export const fetchProfile = async (actor: any): Promise<ProfileViewQuery> => {
20
20
+
const response: Response = await fetch(
21
21
+
`${import.meta.env.VITE_CLIPPR_APPVIEW}/xrpc/social.clippr.actor.getProfile?actor=${actor}`,
22
22
+
);
23
23
+
24
24
+
if (response.status !== 200) {
25
25
+
if (response.status === 400) {
26
26
+
const json: ErrorResponse = await response.json();
27
27
+
console.log(json);
28
28
+
throw new Error(json.message);
29
29
+
} else {
30
30
+
throw new Error(response.statusText);
31
31
+
}
32
32
+
}
33
33
+
34
34
+
return response.json();
35
35
+
};
36
36
+
37
37
+
export const convertDidToHandle = async (did: string): Promise<string> => {
38
38
+
let convertedDid;
39
39
+
if (did.startsWith("did:plc:")) {
40
40
+
convertedDid = did as `did:plc:${string}`;
41
41
+
} else convertedDid = did as `did:web:${string}`;
42
42
+
const docResolver = new CompositeDidDocumentResolver({
43
43
+
methods: {
44
44
+
plc: new PlcDidDocumentResolver(),
45
45
+
web: new WebDidDocumentResolver(),
46
46
+
},
47
47
+
});
48
48
+
49
49
+
let doc;
50
50
+
51
51
+
try {
52
52
+
doc = await docResolver.resolve(convertedDid);
53
53
+
} catch (err) {
54
54
+
if (err instanceof DocumentNotFoundError) {
55
55
+
throw new Error("Document not found");
56
56
+
}
57
57
+
if (err instanceof UnsupportedDidMethodError) {
58
58
+
throw new Error("Unsupported did method");
59
59
+
}
60
60
+
if (err instanceof ImproperDidError) {
61
61
+
throw new Error("Improper did");
62
62
+
}
63
63
+
if (err instanceof FailedDocumentResolutionError) {
64
64
+
throw new Error("Failed document resolution");
65
65
+
}
66
66
+
if (err instanceof HandleResolutionError) {
67
67
+
throw new Error("Generic handle resolution error");
68
68
+
}
69
69
+
}
70
70
+
71
71
+
if (doc === undefined) {
72
72
+
throw new Error("Could not get DID document");
73
73
+
}
74
74
+
75
75
+
if (doc.alsoKnownAs === undefined) {
76
76
+
throw new Error("No handles found");
77
77
+
}
78
78
+
if (doc.alsoKnownAs[0] === undefined) {
79
79
+
throw new Error("No handles found");
80
80
+
}
81
81
+
82
82
+
return doc.alsoKnownAs[0].substring(doc.alsoKnownAs[0].lastIndexOf("/") + 1);
83
83
+
};
+4
-6
frontend/src/views/home.tsx
···
5
5
*/
6
6
7
7
import { loginState } from "../components/loginForm.tsx";
8
8
-
import { ProfileEditor } from "../components/profileEditor.tsx";
9
9
-
import { Profile } from "../components/profile.tsx";
8
8
+
import { ProfileWidget } from "../components/profileWidget.tsx";
9
9
+
import { PageLocation } from "../components/pageLocation.tsx";
10
10
11
11
const Home = () => {
12
12
if (!loginState()) {
···
15
15
16
16
return (
17
17
<main>
18
18
+
<PageLocation lastLocation={"home"} />
18
19
<div id="content">
19
20
<div id="main-content" class="centered">
20
20
-
<h2>home</h2>
21
21
-
<p>OAuth!</p>
22
22
-
<Profile />
23
23
-
<ProfileEditor />
21
21
+
<ProfileWidget />
24
22
</div>
25
23
</div>
26
24
</main>
+3
-1
frontend/src/views/notFound.tsx
···
4
4
* SPDX-License-Identifier: AGPL-3.0-only
5
5
*/
6
6
7
7
+
import { loginState } from "../components/loginForm.tsx";
8
8
+
7
9
const NotFound = () => {
8
10
return (
9
11
<>
···
12
14
<div id="main-content" class="centered">
13
15
<h2>404 | page not found</h2>
14
16
<p>the party seems to be over...</p>
15
15
-
<a href="/">go home</a>
17
17
+
<a href={loginState() ? "/home" : "/"}>go home</a>
16
18
<br />
17
19
<br />
18
20
</div>
+47
frontend/src/views/profile.tsx
···
1
1
+
/*
2
2
+
* clippr: a social bookmarking service for the AT Protocol
3
3
+
* Copyright (c) 2025 clippr contributors.
4
4
+
* SPDX-License-Identifier: AGPL-3.0-only
5
5
+
*/
6
6
+
7
7
+
import { createAsync, useParams } from "@solidjs/router";
8
8
+
import { PageLocation } from "../components/pageLocation.tsx";
9
9
+
import { ProfileWidget } from "../components/profileWidget.tsx";
10
10
+
import { agent, loginState } from "../components/loginForm.tsx";
11
11
+
import { convertDidToHandle } from "../utils/profile.ts";
12
12
+
13
13
+
const Profile = () => {
14
14
+
const params = useParams();
15
15
+
16
16
+
// Authentication check for profile route catch-all
17
17
+
if (!loginState() && location.pathname === "/profile") {
18
18
+
location.href = "/login";
19
19
+
}
20
20
+
21
21
+
let handle;
22
22
+
try {
23
23
+
handle = createAsync(() => convertDidToHandle(params.id || agent.sub));
24
24
+
} catch (e) {
25
25
+
console.error(e);
26
26
+
handle = null;
27
27
+
}
28
28
+
29
29
+
return (
30
30
+
<main>
31
31
+
<PageLocation
32
32
+
path={[{ name: "home", link: `${loginState() ? "/home" : "/"}` }]}
33
33
+
lastLocation={handle || params.id}
34
34
+
/>
35
35
+
<div id="main-headings">
36
36
+
<h1>profile</h1>
37
37
+
</div>
38
38
+
<div id="content">
39
39
+
<div id="main-content" class="centered">
40
40
+
<ProfileWidget actor={params.id} />
41
41
+
</div>
42
42
+
</div>
43
43
+
</main>
44
44
+
);
45
45
+
};
46
46
+
47
47
+
export { Profile };
+31
frontend/src/views/settings.tsx
···
1
1
+
/*
2
2
+
* clippr: a social bookmarking service for the AT Protocol
3
3
+
* Copyright (c) 2025 clippr contributors.
4
4
+
* SPDX-License-Identifier: AGPL-3.0-only
5
5
+
*/
6
6
+
7
7
+
import { ProfileEditor } from "../components/profileEditor.tsx";
8
8
+
import { killSession, loginState } from "../components/loginForm.tsx";
9
9
+
import { PageLocation } from "../components/pageLocation.tsx";
10
10
+
11
11
+
const Settings = () => {
12
12
+
if (!loginState()) {
13
13
+
location.href = "/login";
14
14
+
}
15
15
+
16
16
+
return (
17
17
+
<main>
18
18
+
<PageLocation lastLocation={"settings"} />
19
19
+
<div id="content">
20
20
+
<div id="main-content" class="centered">
21
21
+
<h2>account settings</h2>
22
22
+
<button onclick={killSession}>Log out</button>
23
23
+
<h2>edit profile</h2>
24
24
+
<ProfileEditor />
25
25
+
</div>
26
26
+
</div>
27
27
+
</main>
28
28
+
);
29
29
+
};
30
30
+
31
31
+
export { Settings };