334 lines
15 KiB
TypeScript
334 lines
15 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useForm } from "react-hook-form";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { FiArrowLeft, FiUser, FiLock, FiCamera, FiImage } from "react-icons/fi";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
import { privateApi } from "@/api/privateClient";
|
|
import { mediaUrl } from "@/utils/mediaUrl";
|
|
import Avatar from "@/components/ui/Avatar";
|
|
import Button from "@/components/ui/Button";
|
|
import Spinner from "@/components/ui/Spinner";
|
|
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
|
import { applyServerErrors } from "@/utils/formErrors";
|
|
|
|
type Tab = "profile" | "security";
|
|
|
|
interface ProfileForm {
|
|
first_name: string;
|
|
last_name: string;
|
|
city: string;
|
|
phone_number: string;
|
|
}
|
|
|
|
interface PasswordForm {
|
|
current_password: string;
|
|
new_password: string;
|
|
confirm_password: string;
|
|
}
|
|
|
|
export default function AccountSettingsPage() {
|
|
const { t } = useTranslation("social");
|
|
const { user, refreshUser } = useAuth() as any;
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [tab, setTab] = useState<Tab>("profile");
|
|
|
|
// ── Profile form ──────────────────────────────────────────────
|
|
const [profileSuccess, setProfileSuccess] = useState(false);
|
|
const [profileRootError, setProfileRootError] = useState<string>();
|
|
const profileForm = useForm<ProfileForm>({
|
|
defaultValues: {
|
|
first_name: user?.first_name ?? "",
|
|
last_name: user?.last_name ?? "",
|
|
city: user?.city ?? "",
|
|
phone_number: user?.phone_number ?? "",
|
|
},
|
|
});
|
|
const { register: regProfile, handleSubmit: handleProfile, formState: { isSubmitting: profileSubmitting }, setError: setProfileError } = profileForm;
|
|
|
|
async function onProfileSubmit(values: ProfileForm) {
|
|
setProfileRootError(undefined);
|
|
setProfileSuccess(false);
|
|
try {
|
|
await privateApi.patch(`/api/account/users/${user.id}/`, values);
|
|
setProfileSuccess(true);
|
|
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
|
if (refreshUser) await refreshUser();
|
|
} catch (err) {
|
|
setProfileRootError(applyServerErrors(profileForm, err));
|
|
}
|
|
}
|
|
|
|
// ── Avatar upload ─────────────────────────────────────────────
|
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
|
|
|
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setAvatarPreview(URL.createObjectURL(file));
|
|
setAvatarUploading(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append("avatar", file);
|
|
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
|
|
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
|
if (refreshUser) await refreshUser();
|
|
} finally {
|
|
setAvatarUploading(false);
|
|
e.target.value = "";
|
|
}
|
|
}
|
|
|
|
// ── Banner upload ─────────────────────────────────────────────
|
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
|
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
|
const [bannerUploading, setBannerUploading] = useState(false);
|
|
|
|
async function handleBannerChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setBannerPreview(URL.createObjectURL(file));
|
|
setBannerUploading(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append("banner", file);
|
|
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
|
|
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
|
if (refreshUser) await refreshUser();
|
|
} finally {
|
|
setBannerUploading(false);
|
|
e.target.value = "";
|
|
}
|
|
}
|
|
|
|
// ── Password form ─────────────────────────────────────────────
|
|
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
|
const [passwordRootError, setPasswordRootError] = useState<string>();
|
|
const passwordForm = useForm<PasswordForm>({
|
|
defaultValues: { current_password: "", new_password: "", confirm_password: "" },
|
|
});
|
|
const { register: regPassword, handleSubmit: handlePassword, formState: { isSubmitting: passwordSubmitting }, reset: resetPassword, setError: setPasswordError } = passwordForm;
|
|
|
|
async function onPasswordSubmit(values: PasswordForm) {
|
|
setPasswordRootError(undefined);
|
|
setPasswordSuccess(false);
|
|
if (values.new_password !== values.confirm_password) {
|
|
setPasswordError("confirm_password", { message: "Hesla se neshodují." });
|
|
return;
|
|
}
|
|
try {
|
|
await privateApi.post("/api/account/password-change/", {
|
|
current_password: values.current_password,
|
|
new_password: values.new_password,
|
|
});
|
|
setPasswordSuccess(true);
|
|
resetPassword();
|
|
} catch (err) {
|
|
setPasswordRootError(applyServerErrors(passwordForm, err));
|
|
}
|
|
}
|
|
|
|
const displayName = [user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "?";
|
|
const avatarSrc = avatarPreview ?? mediaUrl((user as any)?.avatar);
|
|
const bannerSrc = bannerPreview ?? mediaUrl((user as any)?.banner);
|
|
|
|
const tabClass = (active: boolean) =>
|
|
[
|
|
"flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
|
active ? "bg-brand-lines/15 text-brand-text" : "text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text",
|
|
].join(" ");
|
|
|
|
const inputClass = "w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/30 focus:outline-none focus:border-brand-accent disabled:opacity-50";
|
|
|
|
return (
|
|
<div>
|
|
<header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(-1)}
|
|
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"
|
|
>
|
|
<FiArrowLeft size={20} />
|
|
</button>
|
|
<h1 className="text-lg font-bold text-brand-text">Nastavení účtu</h1>
|
|
</header>
|
|
|
|
<div className="flex gap-0">
|
|
{/* Sidebar tabs */}
|
|
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-1">
|
|
<button type="button" className={tabClass(tab === "profile")} onClick={() => setTab("profile")}>
|
|
<FiUser size={16} /> Profil
|
|
</button>
|
|
<button type="button" className={tabClass(tab === "security")} onClick={() => setTab("security")}>
|
|
<FiLock size={16} /> Heslo
|
|
</button>
|
|
</nav>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 p-6 max-w-lg">
|
|
|
|
{/* ── Profile tab ── */}
|
|
{tab === "profile" && (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Appearance: banner + avatar */}
|
|
<div>
|
|
<div className="text-sm font-semibold text-brand-text mb-3">Vzhled</div>
|
|
|
|
{/* Banner */}
|
|
<div className="relative mb-10">
|
|
<div
|
|
className="group relative h-28 w-full cursor-pointer overflow-hidden rounded-2xl bg-gradient-to-br from-brand-bgLight to-brand-lines/20"
|
|
onClick={() => bannerInputRef.current?.click()}
|
|
>
|
|
{bannerSrc && (
|
|
<img src={bannerSrc} alt="" className="h-full w-full object-cover" />
|
|
)}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/40">
|
|
{bannerUploading ? (
|
|
<Spinner size={22} />
|
|
) : (
|
|
<div className="flex flex-col items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<FiImage size={20} className="text-white" />
|
|
<span className="text-xs text-white/90">Změnit banner</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<input ref={bannerInputRef} type="file" accept="image/*" className="hidden" onChange={handleBannerChange} />
|
|
|
|
{/* Avatar overlapping banner bottom-left */}
|
|
<div className="absolute -bottom-8 left-4">
|
|
<div className="relative rounded-full ring-4 ring-brand-bg">
|
|
<Avatar name={displayName} src={avatarSrc} size={64} />
|
|
{avatarUploading && (
|
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
|
<Spinner size={16} />
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => avatarInputRef.current?.click()}
|
|
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-brand-accent text-white shadow hover:opacity-90 transition-opacity"
|
|
>
|
|
<FiCamera size={11} />
|
|
</button>
|
|
</div>
|
|
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-brand-text/40">JPG, PNG nebo WebP · Banner max. 5 MB · Avatar max. 5 MB</p>
|
|
</div>
|
|
|
|
{/* Profile form */}
|
|
<form onSubmit={handleProfile(onProfileSubmit)} className="flex flex-col gap-4">
|
|
<FormErrorBanner message={profileRootError} />
|
|
{profileSuccess && (
|
|
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
|
Profil byl uložen.
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Jméno</label>
|
|
<input className={inputClass} placeholder="Jméno" {...regProfile("first_name")} />
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Příjmení</label>
|
|
<input className={inputClass} placeholder="Příjmení" {...regProfile("last_name")} />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Uživatelské jméno</label>
|
|
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.username ?? ""} readOnly disabled />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">E-mail</label>
|
|
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.email ?? ""} readOnly disabled />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Město</label>
|
|
<input className={inputClass} placeholder="Vaše město" {...regProfile("city")} />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Telefon</label>
|
|
<input className={inputClass} placeholder="+420 ..." {...regProfile("phone_number")} />
|
|
</div>
|
|
|
|
<Button type="submit" disabled={profileSubmitting} leftIcon={profileSubmitting ? <Spinner size={14} /> : undefined}>
|
|
{profileSubmitting ? "Ukládání…" : "Uložit profil"}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Security tab ── */}
|
|
{tab === "security" && (
|
|
<form onSubmit={handlePassword(onPasswordSubmit)} className="flex flex-col gap-4">
|
|
<div className="text-sm font-semibold text-brand-text">Změna hesla</div>
|
|
<FormErrorBanner message={passwordRootError} />
|
|
{passwordSuccess && (
|
|
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
|
Heslo bylo změněno.
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Stávající heslo</label>
|
|
<input
|
|
type="password"
|
|
className={inputClass}
|
|
placeholder="••••••••"
|
|
{...regPassword("current_password", { required: "Povinné pole." })}
|
|
/>
|
|
{passwordForm.formState.errors.current_password && (
|
|
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.current_password.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Nové heslo</label>
|
|
<input
|
|
type="password"
|
|
className={inputClass}
|
|
placeholder="••••••••"
|
|
{...regPassword("new_password", { required: "Povinné pole." })}
|
|
/>
|
|
{passwordForm.formState.errors.new_password && (
|
|
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.new_password.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-brand-text/70">Potvrdit nové heslo</label>
|
|
<input
|
|
type="password"
|
|
className={inputClass}
|
|
placeholder="••••••••"
|
|
{...regPassword("confirm_password", { required: "Povinné pole." })}
|
|
/>
|
|
{passwordForm.formState.errors.confirm_password && (
|
|
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.confirm_password.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" disabled={passwordSubmitting} leftIcon={passwordSubmitting ? <Spinner size={14} /> : undefined}>
|
|
{passwordSubmitting ? "Ukládání…" : "Změnit heslo"}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|