avatar: new upload + remove avatar, and placeholder with colour and initials

This commit is contained in:
Oliver Bryan
2026-01-03 11:25:39 +00:00
parent e62b12b1c3
commit 33da8bde85
10 changed files with 138 additions and 54 deletions

View File

@@ -22,7 +22,7 @@ export async function updateById(
updates: { updates: {
name?: string; name?: string;
passwordHash?: string; passwordHash?: string;
avatarURL?: string; avatarURL?: string | null;
}, },
): Promise<UserRecord | undefined> { ): Promise<UserRecord | undefined> {
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();

View File

@@ -18,7 +18,8 @@ export default async function update(req: AuthedRequest) {
const name = url.searchParams.get("name") || undefined; const name = url.searchParams.get("name") || undefined;
const password = url.searchParams.get("password") || undefined; const password = url.searchParams.get("password") || undefined;
const avatarURL = url.searchParams.get("avatarURL") || undefined; const avatarURL =
url.searchParams.get("avatarURL") === "null" ? null : url.searchParams.get("avatarURL") || undefined;
let passwordHash: string | undefined; let passwordHash: string | undefined;
if (password !== undefined) { if (password !== undefined) {
passwordHash = await hashPassword(password); passwordHash = await hashPassword(password);

View File

@@ -9,6 +9,7 @@ import { user } from "@/lib/server";
function Account() { function Account() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null); const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -20,6 +21,7 @@ function Account() {
if (userStr) { if (userStr) {
const user = JSON.parse(userStr) as UserRecord; const user = JSON.parse(userStr) as UserRecord;
setName(user.name); setName(user.name);
setUsername(user.username);
setUserId(user.id); setUserId(user.id);
setAvatarUrl(user.avatarURL || null); setAvatarUrl(user.avatarURL || null);
} }
@@ -42,11 +44,12 @@ function Account() {
id: userId, id: userId,
name: name.trim(), name: name.trim(),
password: password.trim(), password: password.trim(),
avatarURL: avatarURL || undefined, avatarURL: avatarURL,
onSuccess: (data) => { onSuccess: (data) => {
setError(""); setError("");
localStorage.setItem("user", JSON.stringify(data)); localStorage.setItem("user", JSON.stringify(data));
setPassword(""); setPassword("");
window.location.reload();
}, },
onError: (errorMessage) => { onError: (errorMessage) => {
setError(errorMessage); setError(errorMessage);
@@ -58,7 +61,24 @@ function Account() {
<SettingsPageLayout title="Account"> <SettingsPageLayout title="Account">
<form onSubmit={handleSubmit} className="flex flex-col p-4 gap-2 w-sm border"> <form onSubmit={handleSubmit} className="flex flex-col p-4 gap-2 w-sm border">
<h2 className="text-xl font-600 mb-2 text-center">Account Details</h2> <h2 className="text-xl font-600 mb-2 text-center">Account Details</h2>
<UploadAvatar avatarURL={avatarURL} onAvatarUploaded={setAvatarUrl} /> <UploadAvatar
name={name}
username={username}
avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl}
/>
{avatarURL && (
<Button
variant={"dummy"}
type={"button"}
onClick={() => {
setAvatarUrl(null);
}}
className="-mt-2 hover:underline"
>
Remove Avatar
</Button>
)}
<Field <Field
label="Full Name" label="Full Name"
value={name} value={name}

View File

@@ -1,22 +1,76 @@
import type { UserRecord } from "@issue/shared";
import { UserRound } from "lucide-react"; import { UserRound } from "lucide-react";
export default function Avatar({ user, size }: { user?: UserRecord; size?: number }) { const FALLBACK_COLOURS = [
if (user?.avatarURL) { "bg-red-500",
return ( "bg-orange-500",
<img "bg-amber-500",
src={user.avatarURL} "bg-yellow-500",
alt="Avatar" "bg-lime-500",
className={`rounded-full object-cover w-${size || 6} h-${size || 6}`} "bg-green-500",
/> "bg-emerald-500",
); "bg-teal-500",
"bg-cyan-500",
"bg-sky-500",
"bg-blue-500",
"bg-indigo-500",
"bg-violet-500",
"bg-purple-500",
"bg-fuchsia-500",
"bg-pink-500",
"bg-rose-500",
];
function hashStringToIndex(value: string, modulo: number) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
} }
return modulo === 0 ? 0 : hash % modulo;
}
function getInitials(username: string) {
username = username.trim();
const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean);
if (parts.length === 0) return username.slice(0, 2).toUpperCase();
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
export default function Avatar({
avatarURL,
name,
username,
size,
textClass = "text-xs",
}: {
avatarURL?: string | null;
name?: string;
username?: string;
size?: number;
textClass?: string;
}) {
const backgroundClass = username
? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)]
: "bg-muted";
return ( return (
<div <div
className={`flex items-center justify-center rounded-full ${user && "border"} w-${size || 6} h-${size || 6}`} className={`flex items-center justify-center rounded-full text-white font-medium select-none ${name && "border"} ${!avatarURL && backgroundClass} w-${size || 6} h-${size || 6}`}
> >
{user && <UserRound size={size ? size * 2 + 2 : 14} />} {avatarURL ? (
<img
src={avatarURL}
alt="Avatar"
className={`rounded-full object-cover w-${size || 6} h-${size || 6}`}
/>
) : name ? (
<span className={textClass}>{getInitials(name)}</span>
) : (
<UserRound size={size ? size * 2 + 2 : 14} />
)}
</div> </div>
); );
} }

View File

@@ -50,8 +50,15 @@ export function IssuesTable({
<TableCell className="overflow-hide">{issueData.Issue.description}</TableCell> <TableCell className="overflow-hide">{issueData.Issue.description}</TableCell>
)} )}
{(columns.assignee == null || columns.assignee === true) && ( {(columns.assignee == null || columns.assignee === true) && (
<TableCell className={"flex items-center justify-end px-1 py-1"}> <TableCell className={"flex items-center justify-end px-1 py-0 h-[32px]"}>
<Avatar user={issueData.User} /> {issueData.User && (
<Avatar
name={issueData.User?.name}
username={issueData.User?.username}
avatarURL={issueData.User?.avatarURL}
textClass="text-xs"
/>
)}
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>

View File

@@ -129,6 +129,8 @@ export default function LogInForm() {
{mode === "register" && ( {mode === "register" && (
<> <>
<UploadAvatar <UploadAvatar
name={name}
username={username || undefined}
avatarURL={avatarURL} avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl} onAvatarUploaded={setAvatarUrl}
className={"mt-2 mb-4"} className={"mt-2 mb-4"}

View File

@@ -4,7 +4,13 @@ import Avatar from "@/components/avatar";
export default function SmallUserDisplay({ user }: { user: UserRecord }) { export default function SmallUserDisplay({ user }: { user: UserRecord }) {
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Avatar user={user} size={6} /> <Avatar
name={user.name}
username={user.username}
avatarURL={user.avatarURL}
size={6}
textClass="text-xs"
/>
{user.name} {user.name}
</div> </div>
); );

View File

@@ -4,13 +4,17 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { user } from "@/lib/server"; import { user } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Avatar from "./avatar";
export function UploadAvatar({ export function UploadAvatar({
name,
username,
avatarURL, avatarURL,
onAvatarUploaded, onAvatarUploaded,
label,
className, className,
}: { }: {
name?: string;
username?: string;
avatarURL?: string | null; avatarURL?: string | null;
onAvatarUploaded: (avatarURL: string) => void; onAvatarUploaded: (avatarURL: string) => void;
label?: string; label?: string;
@@ -43,7 +47,6 @@ export function UploadAvatar({
return ( return (
<div className={cn("flex flex-col items-center gap-4", className)}> <div className={cn("flex flex-col items-center gap-4", className)}>
{avatarURL && (
<Button <Button
variant="dummy" variant="dummy"
type="button" type="button"
@@ -52,14 +55,20 @@ export function UploadAvatar({
onMouseOut={() => setShowEdit(false)} onMouseOut={() => setShowEdit(false)}
className="w-24 h-24 rounded-full border-1 p-0 relative overflow-hidden" className="w-24 h-24 rounded-full border-1 p-0 relative overflow-hidden"
> >
<img src={avatarURL} alt="Avatar" className={cn("rounded-full")} /> <Avatar
{showEdit && ( name={name}
username={username}
avatarURL={avatarURL}
size={24}
textClass={"text-4xl"}
/>
{!uploading && showEdit && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40"> <div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Edit className="size-6 text-white drop-shadow-md" /> <Edit className="size-6 text-white drop-shadow-md" />
</div> </div>
)} )}
</Button> </Button>
)}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -67,17 +76,6 @@ export function UploadAvatar({
accept="image/png,image/jpeg,image/webp,image/gif" accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden" className="hidden"
/> />
{!avatarURL && (
<Button
variant="outline"
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? "Uploading..." : label || avatarURL ? "Change Avatar" : "Upload Avatar"}
</Button>
)}
{error && <Label className="text-destructive text-sm">{error}</Label>} {error && <Label className="text-destructive text-sm">{error}</Label>}
</div> </div>
); );

View File

@@ -12,15 +12,13 @@ export async function update({
id: number; id: number;
name: string; name: string;
password: string; password: string;
avatarURL?: string; avatarURL: string | null;
} & ServerQueryInput) { } & ServerQueryInput) {
const url = new URL(`${getServerURL()}/user/update`); const url = new URL(`${getServerURL()}/user/update`);
url.searchParams.set("id", `${id}`); url.searchParams.set("id", `${id}`);
url.searchParams.set("name", name.trim()); url.searchParams.set("name", name.trim());
url.searchParams.set("password", password.trim()); url.searchParams.set("password", password.trim());
if (avatarURL) { url.searchParams.set("avatarURL", avatarURL || "null");
url.searchParams.set("avatarURL", avatarURL);
}
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
headers: getAuthHeaders(), headers: getAuthHeaders(),

View File

@@ -8,5 +8,3 @@
- status - status
- sprints - sprints
- time tracking (linked to issues or standalone) - time tracking (linked to issues or standalone)
- for users without avatars, a random colour + initials is shown
- use username as a seed in a random selection of colour list