posts are done
This commit is contained in:
333
frontend/src/pages/social/AccountSettingsPage.tsx
Normal file
333
frontend/src/pages/social/AccountSettingsPage.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user