social bookmarking for atproto

[frontend] Build out basic profile pages

hexmani.ac 65aa84c9 eedf4e54

verified
+296 -102
+1
frontend/package.json
··· 21 21 }, 22 22 "dependencies": { 23 23 "@atcute/client": "^4.0.3", 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 + '@atcute/identity-resolver': 15 + specifier: ^1.1.3 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 + '@atcute/identity-resolver@1.1.3': 62 + resolution: {integrity: sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==} 63 + peerDependencies: 64 + '@atcute/identity': ^1.0.0 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 + 81 + '@atcute/util-fetch@1.0.1': 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 + '@atcute/identity-resolver@1.1.3(@atcute/identity@1.0.3)': 809 + dependencies: 810 + '@atcute/identity': 1.0.3 811 + '@atcute/lexicons': 1.1.0 812 + '@atcute/util-fetch': 1.0.1 813 + '@badrap/valita': 0.4.6 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 + 839 + '@atcute/util-fetch@1.0.1': 840 + dependencies: 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 - import { killSession, loginState } from "./loginForm.tsx"; 7 + import { agent, loginState } from "./loginForm.tsx"; 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 - <a href={loginState() ? "/home" : "/"}> 14 + <A href={loginState() ? "/home" : "/"}> 14 15 <p class="silent-link">clippr (beta)</p> 15 - </a> 16 + </A> 16 17 </div> 17 18 <div id="header-right"> 18 19 <nav> 19 20 {loginState() ? ( 20 - <a onclick={killSession}>logout</a> 21 + <> 22 + <A href={`/profile/${agent.sub}`}>profile</A> 23 + <A href="/settings">settings</A> 24 + </> 21 25 ) : ( 22 - <a href="/login">login</a> 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 + // How to define the path because I forgot how to and struggled for half an hour: 10 + // ``` 11 + // [ 12 + // { 13 + // name: "home", 14 + // link: "/" 15 + // } 16 + // ] 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 + const path = local.path; 16 26 17 - if (typeof local.path === "undefined") { 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 - {local.path.map((item: PathItem) => { 37 + {path.map((item: PathItem) => { 28 38 return ( 29 39 <> 30 40 <p class="page-location-parent" {...others}> 31 - <a href={item.link}>{item.name}</a> 41 + <a href={item.link || ""}>{item.name}</a> 32 42 </p> 33 43 <p>→</p> 34 44 </>
-68
frontend/src/components/profile.tsx
··· 1 - /* 2 - * clippr: a social bookmarking service for the AT Protocol 3 - * Copyright (c) 2025 clippr contributors. 4 - * SPDX-License-Identifier: AGPL-3.0-only 5 - */ 6 - 7 - import { createResource, createSignal, Match, Show, Switch } from "solid-js"; 8 - import { agent } from "./loginForm.tsx"; 9 - import { ErrorResponse, ProfileViewQuery } from "../types.ts"; 10 - 11 - const Profile = () => { 12 - const fetchProfile = async (actor: any): Promise<ProfileViewQuery> => { 13 - const response: Response = await fetch( 14 - `${import.meta.env.VITE_CLIPPR_APPVIEW}/xrpc/social.clippr.actor.getProfile?actor=${actor}`, 15 - ); 16 - 17 - if (response.status !== 200) { 18 - if (response.status === 400) { 19 - const json: ErrorResponse = await response.json(); 20 - console.log(json); 21 - throw new Error(json.message); 22 - } else { 23 - throw new Error(response.statusText); 24 - } 25 - } 26 - 27 - return response.json(); 28 - }; 29 - 30 - const [actor, setActor] = createSignal(); 31 - setActor(agent.session.info.sub); 32 - 33 - const [profile] = createResource(actor, fetchProfile); 34 - 35 - return ( 36 - <div> 37 - <h2>profile</h2> 38 - <Show when={profile.loading}> 39 - <p>loading...</p> 40 - </Show> 41 - <Switch> 42 - <Match when={profile.error}> 43 - <p>error: {profile.error.message}</p> 44 - </Match> 45 - <Match when={profile()}> 46 - <div id="profile-view"> 47 - <img 48 - src={profile()?.avatar} 49 - class="profile-picture" 50 - alt="The user's avatar." 51 - /> 52 - <div> 53 - <p> 54 - <b>{profile()?.displayName}</b> 55 - </p> 56 - <p title={profile()?.did}> 57 - {profile()?.handle.replace("at://", "@")} 58 - </p> 59 - <p>{profile()?.description}</p> 60 - </div> 61 - </div> 62 - </Match> 63 - </Switch> 64 - </div> 65 - ); 66 - }; 67 - 68 - export { Profile };
+18 -17
frontend/src/components/profileEditor.tsx
··· 4 4 * SPDX-License-Identifier: AGPL-3.0-only 5 5 */ 6 6 7 - import { createSignal } from "solid-js"; 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 + import { fetchProfile } from "../utils/profile.ts"; 11 12 12 13 const ProfileEditor = () => { 14 + const [actor, setActor] = createSignal(); 15 + const [avatarPreview, setAvatarPreview] = createSignal(""); 13 16 const [notice, setNotice] = createSignal(""); 14 17 let formRef: HTMLFormElement = document.createElement("form"); 15 18 19 + setActor(agent.session.info.sub); 20 + const [profile] = createResource(actor, fetchProfile); 21 + 16 22 const uploadBlob = async () => { 17 23 setNotice(""); 18 - console.log("starting upload..."); 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 - // @ts-ignore 50 - const uploadRes: ClientResponse<any, any> = await rpc.post("com.atproto.repo.uploadBlob", 55 + const uploadRes: ClientResponse<any, any> = await rpc.post( 56 + // @ts-ignore 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 - setNotice("avatar has been uploaded!"); 67 + setNotice(""); 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 - if ( 78 - displayName === null || 79 - displayName === "" 80 - ) { 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 - if ( 92 - description === null || 93 - description === "" 94 - ) { 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 - setTimeout(() => { 133 - window.location.reload(); 134 - }, 1000); 135 134 }; 136 135 137 136 return ( 138 137 <div> 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 + <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 + 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 + value={profile()?.description || ""} 165 166 ></textarea> 166 167 <button 167 168 type="submit"
+60
frontend/src/components/profileWidget.tsx
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { 8 + createResource, 9 + Match, 10 + Show, 11 + splitProps, 12 + Switch, 13 + } from "solid-js"; 14 + import { agent } from "./loginForm.tsx"; 15 + import { fetchProfile } from "../utils/profile.ts"; 16 + 17 + interface ProfileProps { 18 + actor?: string; 19 + } 20 + 21 + const ProfileWidget = (props: ProfileProps) => { 22 + const [local] = splitProps(props, ["actor"]); 23 + const actor = () => local.actor ?? agent.session.info.sub; 24 + 25 + 26 + const [profile] = createResource(actor, fetchProfile); 27 + 28 + return ( 29 + <div> 30 + <Show when={profile.loading}> 31 + <p>loading...</p> 32 + </Show> 33 + <Switch> 34 + <Match when={profile.error}> 35 + <p>error: {profile.error.message}</p> 36 + </Match> 37 + <Match when={profile()}> 38 + <div id="profile-view"> 39 + <img 40 + src={profile()?.avatar} 41 + class="profile-picture" 42 + alt="The user's avatar." 43 + /> 44 + <div> 45 + <p> 46 + <b>{profile()?.displayName}</b> 47 + </p> 48 + <p title={profile()?.did}> 49 + {profile()?.handle.replace("at://", "@")} 50 + </p> 51 + <p>{profile()?.description}</p> 52 + </div> 53 + </div> 54 + </Match> 55 + </Switch> 56 + </div> 57 + ); 58 + }; 59 + 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 + import { Profile } from "./views/profile.tsx"; 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 + <Route path={["/profile/:id", "/profile"]} component={Profile} /> 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 - margin: 1rem 0 2rem 0; 67 + margin: 1rem 0 1rem 0; 68 68 } 69 69 70 70 #page-location { 71 71 display: flex; 72 72 align-items: start; 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 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { ErrorResponse, ProfileViewQuery } from "../types.ts"; 8 + import { 9 + CompositeDidDocumentResolver, 10 + DocumentNotFoundError, 11 + FailedDocumentResolutionError, 12 + HandleResolutionError, 13 + ImproperDidError, 14 + PlcDidDocumentResolver, 15 + UnsupportedDidMethodError, 16 + WebDidDocumentResolver, 17 + } from "@atcute/identity-resolver"; 18 + 19 + export const fetchProfile = async (actor: any): Promise<ProfileViewQuery> => { 20 + const response: Response = await fetch( 21 + `${import.meta.env.VITE_CLIPPR_APPVIEW}/xrpc/social.clippr.actor.getProfile?actor=${actor}`, 22 + ); 23 + 24 + if (response.status !== 200) { 25 + if (response.status === 400) { 26 + const json: ErrorResponse = await response.json(); 27 + console.log(json); 28 + throw new Error(json.message); 29 + } else { 30 + throw new Error(response.statusText); 31 + } 32 + } 33 + 34 + return response.json(); 35 + }; 36 + 37 + export const convertDidToHandle = async (did: string): Promise<string> => { 38 + let convertedDid; 39 + if (did.startsWith("did:plc:")) { 40 + convertedDid = did as `did:plc:${string}`; 41 + } else convertedDid = did as `did:web:${string}`; 42 + const docResolver = new CompositeDidDocumentResolver({ 43 + methods: { 44 + plc: new PlcDidDocumentResolver(), 45 + web: new WebDidDocumentResolver(), 46 + }, 47 + }); 48 + 49 + let doc; 50 + 51 + try { 52 + doc = await docResolver.resolve(convertedDid); 53 + } catch (err) { 54 + if (err instanceof DocumentNotFoundError) { 55 + throw new Error("Document not found"); 56 + } 57 + if (err instanceof UnsupportedDidMethodError) { 58 + throw new Error("Unsupported did method"); 59 + } 60 + if (err instanceof ImproperDidError) { 61 + throw new Error("Improper did"); 62 + } 63 + if (err instanceof FailedDocumentResolutionError) { 64 + throw new Error("Failed document resolution"); 65 + } 66 + if (err instanceof HandleResolutionError) { 67 + throw new Error("Generic handle resolution error"); 68 + } 69 + } 70 + 71 + if (doc === undefined) { 72 + throw new Error("Could not get DID document"); 73 + } 74 + 75 + if (doc.alsoKnownAs === undefined) { 76 + throw new Error("No handles found"); 77 + } 78 + if (doc.alsoKnownAs[0] === undefined) { 79 + throw new Error("No handles found"); 80 + } 81 + 82 + return doc.alsoKnownAs[0].substring(doc.alsoKnownAs[0].lastIndexOf("/") + 1); 83 + };
+4 -6
frontend/src/views/home.tsx
··· 5 5 */ 6 6 7 7 import { loginState } from "../components/loginForm.tsx"; 8 - import { ProfileEditor } from "../components/profileEditor.tsx"; 9 - import { Profile } from "../components/profile.tsx"; 8 + import { ProfileWidget } from "../components/profileWidget.tsx"; 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 + <PageLocation lastLocation={"home"} /> 18 19 <div id="content"> 19 20 <div id="main-content" class="centered"> 20 - <h2>home</h2> 21 - <p>OAuth!</p> 22 - <Profile /> 23 - <ProfileEditor /> 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 + import { loginState } from "../components/loginForm.tsx"; 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 - <a href="/">go home</a> 17 + <a href={loginState() ? "/home" : "/"}>go home</a> 16 18 <br /> 17 19 <br /> 18 20 </div>
+47
frontend/src/views/profile.tsx
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { createAsync, useParams } from "@solidjs/router"; 8 + import { PageLocation } from "../components/pageLocation.tsx"; 9 + import { ProfileWidget } from "../components/profileWidget.tsx"; 10 + import { agent, loginState } from "../components/loginForm.tsx"; 11 + import { convertDidToHandle } from "../utils/profile.ts"; 12 + 13 + const Profile = () => { 14 + const params = useParams(); 15 + 16 + // Authentication check for profile route catch-all 17 + if (!loginState() && location.pathname === "/profile") { 18 + location.href = "/login"; 19 + } 20 + 21 + let handle; 22 + try { 23 + handle = createAsync(() => convertDidToHandle(params.id || agent.sub)); 24 + } catch (e) { 25 + console.error(e); 26 + handle = null; 27 + } 28 + 29 + return ( 30 + <main> 31 + <PageLocation 32 + path={[{ name: "home", link: `${loginState() ? "/home" : "/"}` }]} 33 + lastLocation={handle || params.id} 34 + /> 35 + <div id="main-headings"> 36 + <h1>profile</h1> 37 + </div> 38 + <div id="content"> 39 + <div id="main-content" class="centered"> 40 + <ProfileWidget actor={params.id} /> 41 + </div> 42 + </div> 43 + </main> 44 + ); 45 + }; 46 + 47 + export { Profile };
+31
frontend/src/views/settings.tsx
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { ProfileEditor } from "../components/profileEditor.tsx"; 8 + import { killSession, loginState } from "../components/loginForm.tsx"; 9 + import { PageLocation } from "../components/pageLocation.tsx"; 10 + 11 + const Settings = () => { 12 + if (!loginState()) { 13 + location.href = "/login"; 14 + } 15 + 16 + return ( 17 + <main> 18 + <PageLocation lastLocation={"settings"} /> 19 + <div id="content"> 20 + <div id="main-content" class="centered"> 21 + <h2>account settings</h2> 22 + <button onclick={killSession}>Log out</button> 23 + <h2>edit profile</h2> 24 + <ProfileEditor /> 25 + </div> 26 + </div> 27 + </main> 28 + ); 29 + }; 30 + 31 + export { Settings };