+18
-3
frontend/src/components/profile.tsx
+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
+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
+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
+9
frontend/src/types.ts
+1
-4
frontend/src/views/home.tsx
+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>