mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
avatar: new upload + remove avatar, and placeholder with colour and initials
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,23 +47,28 @@ 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"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onMouseOver={() => setShowEdit(true)}
|
||||||
onMouseOver={() => setShowEdit(true)}
|
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"
|
>
|
||||||
>
|
<Avatar
|
||||||
<img src={avatarURL} alt="Avatar" className={cn("rounded-full")} />
|
name={name}
|
||||||
{showEdit && (
|
username={username}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
avatarURL={avatarURL}
|
||||||
<Edit className="size-6 text-white drop-shadow-md" />
|
size={24}
|
||||||
</div>
|
textClass={"text-4xl"}
|
||||||
)}
|
/>
|
||||||
</Button>
|
|
||||||
)}
|
{!uploading && showEdit && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||||
|
<Edit className="size-6 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user