posts are done

This commit is contained in:
2026-05-19 00:08:02 +02:00
parent 202ce22102
commit 2e9e3ed41b
35 changed files with 1528 additions and 272 deletions

View 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>
);
}