183 lines
7.4 KiB
TypeScript
183 lines
7.4 KiB
TypeScript
// app/portal/account/AccountClient.tsx
|
||
"use client";
|
||
|
||
import { useState } from "react";
|
||
|
||
type Me = {
|
||
id: string;
|
||
username: string;
|
||
first_name?: string | null;
|
||
last_name?: string | null;
|
||
email?: string | null;
|
||
location?: string | null;
|
||
avatar?: { id: string; filename_download?: string; title?: string } | string | null;
|
||
};
|
||
|
||
export default function AccountClient({ initialUser }: { initialUser: Me }) {
|
||
const [user, setUser] = useState<Me>(initialUser);
|
||
const [saving, setSaving] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
|
||
const [firstName, setFirst] = useState(user.first_name ?? "");
|
||
const [lastName, setLast] = useState(user.last_name ?? "");
|
||
const [email, setEmail] = useState(user.email ?? "");
|
||
const [location, setLocation] = useState(user.location ?? "");
|
||
|
||
const [pwCurr, setPwCurr] = useState("");
|
||
const [pwNew, setPwNew] = useState("");
|
||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
const avatarThumb = (() => {
|
||
// Directus file thumbnails: /assets/{id}?download=... if you expose assets
|
||
const id = typeof user.avatar === "object" ? user.avatar?.id : user.avatar;
|
||
if (!id) return null;
|
||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||
return `${base}/assets/${id}?width=96&height=96&fit=cover`;
|
||
})();
|
||
|
||
async function saveProfile(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
setMsg(null);
|
||
try {
|
||
const res = await fetch("/api/account", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
first_name: firstName.trim(),
|
||
last_name: lastName.trim(),
|
||
email: email.trim(),
|
||
location: location.trim(),
|
||
}),
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j?.error || "Update failed");
|
||
setUser((u) => ({ ...u, ...j }));
|
||
setMsg("Profile updated.");
|
||
} catch (err: any) {
|
||
setMsg(err?.message || "Failed to update.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setUploading(true);
|
||
setMsg(null);
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("file", file, file.name);
|
||
const res = await fetch("/api/account/avatar", { method: "POST", body: fd, credentials: "include" });
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j?.error || "Upload failed");
|
||
// Refresh our user record
|
||
setUser((u) => ({ ...u, avatar: j.avatar }));
|
||
setMsg("Avatar updated.");
|
||
} catch (err: any) {
|
||
setMsg(err?.message || "Failed to upload avatar.");
|
||
} finally {
|
||
setUploading(false);
|
||
e.currentTarget.value = "";
|
||
}
|
||
}
|
||
|
||
async function changePassword(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setPwMsg(null);
|
||
try {
|
||
const res = await fetch("/api/account/password", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ current_password: pwCurr, new_password: pwNew }),
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j?.error || "Password change failed");
|
||
setPwMsg("Password updated.");
|
||
setPwCurr("");
|
||
setPwNew("");
|
||
} catch (err: any) {
|
||
setPwMsg(err?.message || "Failed to update password.");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-2xl space-y-8">
|
||
<section className="rounded-lg border p-4">
|
||
<h2 className="text-lg font-semibold mb-3">Account</h2>
|
||
|
||
<div className="mb-4">
|
||
<label className="text-sm text-muted-foreground">Username (read-only)</label>
|
||
<div className="mt-1">
|
||
<input className="w-full border rounded px-2 py-1 bg-muted" value={user.username} readOnly />
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-1">Usernames can’t be changed.</p>
|
||
</div>
|
||
|
||
<form onSubmit={saveProfile} className="grid gap-3">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-sm">First name</label>
|
||
<input className="w-full border rounded px-2 py-1" value={firstName} onChange={(e) => setFirst(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm">Last name</label>
|
||
<input className="w-full border rounded px-2 py-1" value={lastName} onChange={(e) => setLast(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm">Email</label>
|
||
<input type="email" className="w-full border rounded px-2 py-1" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm">Location</label>
|
||
<input className="w-full border rounded px-2 py-1" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 mt-2">
|
||
<div className="w-24 h-24 rounded-full overflow-hidden border bg-muted">
|
||
{avatarThumb ? <img src={avatarThumb} alt="avatar" className="w-full h-full object-cover" /> : null}
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">Avatar</label>
|
||
<input type="file" accept="image/*" onChange={onAvatarChange} disabled={uploading} />
|
||
{uploading ? <div className="text-xs opacity-70 mt-1">Uploading…</div> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<button disabled={saving} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90">
|
||
{saving ? "Saving…" : "Save changes"}
|
||
</button>
|
||
{msg ? <span className="text-sm">{msg}</span> : null}
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<section className="rounded-lg border p-4">
|
||
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
|
||
<form onSubmit={changePassword} className="grid gap-3 max-w-md">
|
||
<div>
|
||
<label className="text-sm">Current password</label>
|
||
<input type="password" className="w-full border rounded px-2 py-1" value={pwCurr} onChange={(e) => setPwCurr(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm">New password</label>
|
||
<input type="password" className="w-full border rounded px-2 py-1" value={pwNew} onChange={(e) => setPwNew(e.target.value)} />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button className="px-3 py-2 border rounded hover:bg-muted">Update password</button>
|
||
{pwMsg ? <span className="text-sm">{pwMsg}</span> : null}
|
||
</div>
|
||
</form>
|
||
<p className="text-xs text-muted-foreground mt-2">
|
||
If this fails, enable password updates for your role or use the email reset flow.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|