social bookmarking for atproto

[frontend] Parse JSON response and turn into a proper profile view

hexmani.ac eedf4e54 8492f766

verified
Changed files
+130 -15
frontend
+18 -3
frontend/src/components/profile.tsx
··· 6 6 7 7 import { createResource, createSignal, Match, Show, Switch } from "solid-js"; 8 8 import { agent } from "./loginForm.tsx"; 9 - import { ErrorResponse } from "../types.ts"; 9 + import { ErrorResponse, ProfileViewQuery } from "../types.ts"; 10 10 11 11 const Profile = () => { 12 - const fetchProfile = async (actor: any) => { 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 ); ··· 43 43 <p>error: {profile.error.message}</p> 44 44 </Match> 45 45 <Match when={profile()}> 46 - <p>profile: {JSON.stringify(profile())}</p> 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> 47 62 </Match> 48 63 </Switch> 49 64 </div>
+45 -8
frontend/src/components/profileEditor.tsx
··· 21 21 const file = (document.getElementById("avatar") as HTMLInputElement) 22 22 ?.files?.[0]; 23 23 if (!file) return; 24 - console.log(file); 25 24 26 25 if (!file.type.startsWith("image/")) { 27 26 setNotice("error: avatar must be an image"); 28 - console.log("error: avatar must be an image"); 27 + console.log(file); 29 28 return; 30 29 } 31 30 32 31 if (file.size > 1000000) { 33 32 setNotice("error: avatar must be less than 1MB"); 34 - console.log("error: avatar must be less than 1MB"); 33 + console.log(file); 35 34 return; 36 35 } 37 36 ··· 48 47 const rpc = new Client({ handler: agent! }); 49 48 setNotice("uploading avatar..."); 50 49 // @ts-ignore 51 - const uploadRes: ClientResponse<any, any> = await rpc.post( "com.atproto.repo.uploadBlob", 50 + const uploadRes: ClientResponse<any, any> = await rpc.post("com.atproto.repo.uploadBlob", 52 51 { 53 52 input: blob, 54 53 }, ··· 74 73 return; 75 74 } 76 75 77 - if (formData.get("displayName") === null) { 76 + const displayName = formData.get("displayName") as string; 77 + if ( 78 + displayName === null || 79 + displayName === "" 80 + ) { 78 81 setNotice("error: display name is missing"); 82 + return; 83 + } 84 + 85 + if (displayName.length > 64) { 86 + setNotice("error: display name is too long"); 87 + return; 88 + } 89 + 90 + let description = formData.get("description") as string; 91 + if ( 92 + description === null || 93 + description === "" 94 + ) { 95 + description = "This user does not have a bio."; 96 + } 97 + 98 + if (description.length > 500) { 99 + setNotice("error: description is too long"); 100 + return; 79 101 } 80 102 81 103 try { ··· 90 112 avatar: JSON.parse(avatar), 91 113 displayName: formData.get("displayName"), 92 114 description: formData.get("description") || "", 115 + // TODO: Take 'createdAt' string from previous version if it exists 93 116 createdAt: new Date().toISOString(), 94 117 }, 95 118 }, ··· 105 128 } 106 129 107 130 setNotice("profile changed!"); 131 + localStorage.removeItem("avatar"); 108 132 setTimeout(() => { 109 133 window.location.reload(); 110 134 }, 1000); ··· 114 138 <div> 115 139 <h2>profile editor</h2> 116 140 <form ref={formRef}> 117 - <label for="avatar">avatar</label> 141 + <label for="avatar" class="file-upload"> 142 + upload avatar 143 + </label> 118 144 <input 119 145 type="file" 120 146 name="avatar" ··· 123 149 onChange={() => uploadBlob()} 124 150 /> 125 151 <label for="displayName">display name</label> 126 - <input type="text" name="displayName" id="displayName" /> 152 + <input 153 + type="text" 154 + name="displayName" 155 + id="displayName" 156 + maxLength="64" 157 + placeholder="Alice" 158 + /> 127 159 <label for="description">bio</label> 128 - <textarea name="description" id="description"></textarea> 160 + <textarea 161 + name="description" 162 + id="description" 163 + maxLength="500" 164 + placeholder="describe yourself..." 165 + ></textarea> 129 166 <button 130 167 type="submit" 131 168 onClick={(e) => {
+57
frontend/src/styles/index.css
··· 13 13 :root { 14 14 --bg: #222 !important; 15 15 --fg: #fff !important; 16 + --controls-bg: #2B2A33 !important; 17 + --controls-bg-hover: #52525E !important; 18 + --controls-border: #8F8F9D !important; 16 19 } 17 20 } 18 21 ··· 20 23 :root { 21 24 --bg: #fff !important; 22 25 --fg: #222 !important; 26 + --controls-bg: #E9E9ED !important; 27 + --controls-bg-hover: #D0D0D7 !important; 28 + --controls-border: #8F8F9D !important; 23 29 } 24 30 } 25 31 ··· 187 193 } 188 194 } 189 195 196 + #profile-view { 197 + display: flex; 198 + flex-direction: row; 199 + align-items: center; 200 + gap: 2rem; 201 + 202 + div { 203 + text-align: left; 204 + } 205 + 206 + * { 207 + margin: 0.5rem 0; 208 + } 209 + } 210 + 211 + .profile-picture { 212 + border-radius: 50%; 213 + width: 150px; 214 + height: 150px; 215 + } 216 + 217 + form input[type="file"] { 218 + display: none; 219 + } 220 + 221 + .file-upload { 222 + border: 1px solid var(--controls-border); 223 + display: inline-block; 224 + padding: 6px 12px; 225 + background-color: var(--controls-bg); 226 + border-radius: 6px; 227 + margin: 0.5rem 0; 228 + } 229 + 230 + .file-upload:hover { 231 + background-color: var(--controls-bg-hover); 232 + } 233 + 234 + textarea { 235 + padding: 0.5rem; 236 + width: 275px; 237 + height: 100px; 238 + font-family: Arial, sans-serif; 239 + } 240 + 190 241 @media (max-width: 768px) { 191 242 body { 192 243 width: 90vw; ··· 199 250 200 251 #content { 201 252 flex-direction: column; 253 + } 254 + 255 + #profile-view { 256 + flex-direction: column; 257 + align-items: center; 258 + gap: 0.1rem; 202 259 } 203 260 204 261 footer {
+9
frontend/src/types.ts
··· 8 8 error: string; 9 9 message: string; 10 10 }; 11 + 12 + export type ProfileViewQuery = Object & { 13 + did: string; 14 + handle: string; 15 + displayName: string; 16 + avatar: string; 17 + description: string; 18 + createdAt: string; 19 + }
+1 -4
frontend/src/views/home.tsx
··· 4 4 * SPDX-License-Identifier: AGPL-3.0-only 5 5 */ 6 6 7 - import { killSession, loginState } from "../components/loginForm.tsx"; 7 + import { loginState } from "../components/loginForm.tsx"; 8 8 import { ProfileEditor } from "../components/profileEditor.tsx"; 9 9 import { Profile } from "../components/profile.tsx"; 10 10 ··· 21 21 <p>OAuth!</p> 22 22 <Profile /> 23 23 <ProfileEditor /> 24 - <button type="button" onClick={killSession}> 25 - Log out 26 - </button> 27 24 </div> 28 25 </div> 29 26 </main>