done last commit before merging - fixed media URLSs S3

This commit is contained in:
2026-06-12 00:56:01 +02:00
parent f4c4a8bfd1
commit 44e77e7744
19 changed files with 1478 additions and 193 deletions

View File

@@ -1,5 +1,5 @@
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
VITE_BACKEND_URL="http://localhost:8000/api/"
VITE_BACKEND_URL="http://localhost:8000/"
VITE_BACKEND_WS_URL="ws://localhost:8000/"
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL

View File

@@ -16,11 +16,13 @@ import PublicOnlyRoute from "./routes/PublicOnlyRoute";
import PortfolioPage from "./pages/portfolio/PortfolioPage";
import ContactPage from "./pages/contact/ContactPage";
import ScrollToTop from "./components/common/ScrollToTop";
import TopBanner from "./components/common/TopBanner";
import LogoutPage from "./pages/social/account/Logout";
import LoginPage from "./pages/social/account/Login";
import RegisterPage from "./pages/social/account/Register";
import PasswordResetPage from "./pages/social/account/PasswordResetPage";
import ConfirmEmailChangePage from "./pages/social/account/ConfirmEmailChangePage";
import { RetroSoundTest } from "./pages/test/sounds";
// Social pages
@@ -41,6 +43,7 @@ export default function App() {
return (
<Router>
<ScrollToTop />
<TopBanner />
<Routes>
{/* Public marketing routes */}
<Route path="/" element={<HomeLayout />}>
@@ -61,6 +64,9 @@ export default function App() {
<Route path="password-reset" element={<PasswordResetPage />} />
</Route>
{/* Email change confirmation — public, verified by token */}
<Route path="/account/confirm-email-change/:uidb64/:token" element={<ConfirmEmailChangePage />} />
{/* Authenticated social area */}
<Route path="/social" element={<PrivateRoute />}>
<Route element={<SocialLayout />}>

View File

@@ -57,6 +57,420 @@ type NonReadonly<T> = [T] extends [UnionToIntersection<T>]
}
: DistributeReadOnlyOverUnions<T>;
/**
* Validates current password + Turnstile, stores pending_email, sends confirmation link to the new address and a notification to the old one.
* @summary Request email address change
*/
export const apiAccountChangeEmailCreate = (signal?: AbortSignal) => {
return privateMutator<void>({
url: `/api/account/change-email/`,
method: "POST",
signal,
});
};
export const getApiAccountChangeEmailCreateMutationOptions = <
TError = void,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
TError,
void,
TContext
> => {
const mutationKey = ["apiAccountChangeEmailCreate"];
const { mutation: mutationOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
void
> = () => {
return apiAccountChangeEmailCreate();
};
return { mutationFn, ...mutationOptions };
};
export type ApiAccountChangeEmailCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>
>;
export type ApiAccountChangeEmailCreateMutationError = void;
/**
* @summary Request email address change
*/
export const useApiAccountChangeEmailCreate = <
TError = void,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
TError,
void,
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
TError,
void,
TContext
> => {
return useMutation(
getApiAccountChangeEmailCreateMutationOptions(options),
queryClient,
);
};
/**
* Updates the username. Requires Turnstile. Limited to once per 30 days.
* @summary Change username
*/
export const apiAccountChangeUsernameCreate = (signal?: AbortSignal) => {
return privateMutator<void>({
url: `/api/account/change-username/`,
method: "POST",
signal,
});
};
export const getApiAccountChangeUsernameCreateMutationOptions = <
TError = void,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
TError,
void,
TContext
> => {
const mutationKey = ["apiAccountChangeUsernameCreate"];
const { mutation: mutationOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
void
> = () => {
return apiAccountChangeUsernameCreate();
};
return { mutationFn, ...mutationOptions };
};
export type ApiAccountChangeUsernameCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>
>;
export type ApiAccountChangeUsernameCreateMutationError = void;
/**
* @summary Change username
*/
export const useApiAccountChangeUsernameCreate = <
TError = void,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
TError,
void,
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
TError,
void,
TContext
> => {
return useMutation(
getApiAccountChangeUsernameCreateMutationOptions(options),
queryClient,
);
};
/**
* Verifies uid + token from the confirmation email, swaps email to pending_email.
* @summary Confirm email address change via link
*/
export const apiAccountConfirmEmailChangeRetrieve = (
uidb64: string,
token: string,
signal?: AbortSignal,
) => {
return privateMutator<void>({
url: `/api/account/confirm-email-change/${uidb64}/${token}/`,
method: "GET",
signal,
});
};
export const getApiAccountConfirmEmailChangeRetrieveQueryKey = (
uidb64: string,
token: string,
) => {
return [`/api/account/confirm-email-change/${uidb64}/${token}/`] as const;
};
export const getApiAccountConfirmEmailChangeRetrieveQueryOptions = <
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError = void,
>(
uidb64: string,
token: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
TData
>
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getApiAccountConfirmEmailChangeRetrieveQueryKey(uidb64, token);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
> = ({ signal }) =>
apiAccountConfirmEmailChangeRetrieve(uidb64, token, signal);
return {
queryKey,
queryFn,
enabled: !!(uidb64 && token),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ApiAccountConfirmEmailChangeRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
>;
export type ApiAccountConfirmEmailChangeRetrieveQueryError = void;
export function useApiAccountConfirmEmailChangeRetrieve<
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError = void,
>(
uidb64: string,
token: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiAccountConfirmEmailChangeRetrieve<
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError = void,
>(
uidb64: string,
token: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiAccountConfirmEmailChangeRetrieve<
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError = void,
>(
uidb64: string,
token: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Confirm email address change via link
*/
export function useApiAccountConfirmEmailChangeRetrieve<
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError = void,
>(
uidb64: string,
token: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getApiAccountConfirmEmailChangeRetrieveQueryOptions(
uidb64,
token,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Soft-deletes the authenticated user's account after password confirmation. Clears auth cookies.
* @summary Delete own account
*/
export const apiAccountDeleteCreate = (signal?: AbortSignal) => {
return privateMutator<void>({
url: `/api/account/delete/`,
method: "POST",
signal,
});
};
export const getApiAccountDeleteCreateMutationOptions = <
TError = void,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
TError,
void,
TContext
> => {
const mutationKey = ["apiAccountDeleteCreate"];
const { mutation: mutationOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
void
> = () => {
return apiAccountDeleteCreate();
};
return { mutationFn, ...mutationOptions };
};
export type ApiAccountDeleteCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiAccountDeleteCreate>>
>;
export type ApiAccountDeleteCreateMutationError = void;
/**
* @summary Delete own account
*/
export const useApiAccountDeleteCreate = <TError = void, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
TError,
void,
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
TError,
void,
TContext
> => {
return useMutation(
getApiAccountDeleteCreateMutationOptions(options),
queryClient,
);
};
/**
* @summary Change password for the authenticated user
*/
@@ -143,6 +557,86 @@ export const useApiAccountPasswordChangeCreate = <
queryClient,
);
};
/**
* Resends the verification email to the currently authenticated user. Limited to once per minute.
* @summary Resend email verification
*/
export const apiAccountResendVerificationCreate = (signal?: AbortSignal) => {
return privateMutator<void>({
url: `/api/account/resend-verification/`,
method: "POST",
signal,
});
};
export const getApiAccountResendVerificationCreateMutationOptions = <
TError = void,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
TError,
void,
TContext
> => {
const mutationKey = ["apiAccountResendVerificationCreate"];
const { mutation: mutationOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
void
> = () => {
return apiAccountResendVerificationCreate();
};
return { mutationFn, ...mutationOptions };
};
export type ApiAccountResendVerificationCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>
>;
export type ApiAccountResendVerificationCreateMutationError = void;
/**
* @summary Resend email verification
*/
export const useApiAccountResendVerificationCreate = <
TError = void,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
TError,
void,
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
TError,
void,
TContext
> => {
return useMutation(
getApiAccountResendVerificationCreateMutationOptions(options),
queryClient,
);
};
/**
* Returns details of the currently authenticated user based on JWT token or session.
* @summary Get current authenticated user

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from "react";
import { FiAlertCircle, FiMail } from "react-icons/fi";
import { useAuth } from "@/hooks/useAuth";
import { privateApi } from "@/api/privateClient";
export const BANNER_H = "40px";
export default function TopBanner() {
const { user, isLoading } = useAuth();
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [cooldown, setCooldown] = useState(0);
const show = !isLoading && !!user && user.email_verified === false;
useEffect(() => {
document.documentElement.style.setProperty("--top-banner-h", show ? BANNER_H : "0px");
}, [show]);
useEffect(() => {
if (cooldown <= 0) return;
const t = setTimeout(() => setCooldown((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [cooldown]);
async function handleResend() {
setSending(true);
try {
await privateApi.post("/api/account/resend-verification/");
setSent(true);
} catch (err: any) {
const secs = err?.response?.data?.seconds_remaining;
if (secs) setCooldown(secs);
} finally {
setSending(false);
}
}
if (!show) return null;
return (
<div
role="alert"
aria-live="polite"
className="fixed top-0 left-0 right-0 z-[200] flex items-center justify-center gap-2.5 px-12 text-amber-200 text-[0.8rem]"
style={{
height: BANNER_H,
background: "color-mix(in hsl, #92400e, var(--c-background) 15%)",
borderBottom: "1px solid color-mix(in hsl, #f59e0b, transparent 55%)",
}}
>
<FiAlertCircle size={14} className="shrink-0 text-amber-400" />
<span>Tvůj e-mail ještě není ověřen.</span>
{sent ? (
<span className="text-emerald-300 font-semibold">E-mail odeslán!</span>
) : (
<button
onClick={handleResend}
disabled={sending || cooldown > 0}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-400/55 bg-amber-400/25 px-2.5 py-0.5 text-[0.75rem] font-semibold text-amber-200 transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
>
<FiMail size={11} />
{sending ? "Odesílám…" : cooldown > 0 ? `Počkej ${cooldown}s` : "Odeslat znovu"}
</button>
)}
</div>
);
}

View File

@@ -132,7 +132,7 @@ export default function ContactMeForm() {
{error && (
<p style={{ color: "#ff6b6b", fontSize: "0.8rem", margin: "0", textAlign: "center" }}>{error}</p>
)}
{turnstileEnabled && <div style={{ display: "flex", justifyContent: "center" }}><div ref={containerRef} /></div>}
{turnstileEnabled && <div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}><div ref={containerRef} /></div>}
<input type="submit" value={loading ? t("contact.sendingButton") : t("contact.sendButton")} disabled={loading || !turnstileToken} />
</form>
</div>

View File

@@ -47,7 +47,7 @@
transition: all 1s ease-out;
}
.content-moveup{
transform: translateY(-70%);
transform: translateY(-80%);
}
.content-moveup-index { z-index: 2 !important; }

View File

@@ -15,7 +15,7 @@
justify-content: space-between;
align-items: center;
position: fixed;
top: 1rem;
top: calc(1rem + var(--top-banner-h, 0px));
left: 50%;
transform: translateX(-50%);
z-index: 100;
@@ -292,7 +292,7 @@
.navbar {
width: 100%;
max-width: 100%;
top: 0;
top: var(--top-banner-h, 0px);
border-radius: 0;
padding: 0.7em 1.2em;
border-left: none;

View File

@@ -59,7 +59,7 @@ export default function SocialLayout() {
* This ensures the middle row is always exactly the right height so
* nothing is hidden behind the fixed bottom nav.
*/
<div className="flex flex-col" style={{ height: "100svh" }}>
<div className="flex flex-col" style={{ height: "100svh", paddingTop: "var(--top-banner-h, 0px)" }}>
{/* ── Mobile top bar ── */}
<div
@@ -172,7 +172,7 @@ export default function SocialLayout() {
{/* ── Fixed bottom tab bar — mobile only ── */}
<nav
className="md:hidden"
className="flex md:hidden"
style={{
position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 50,
height: BOTTOM_NAV_H,
@@ -180,7 +180,6 @@ export default function SocialLayout() {
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
borderTop: "1px solid color-mix(in hsl, var(--c-lines), transparent 65%)",
display: "flex",
}}
>
{items.map((it) => (

View File

@@ -3,8 +3,12 @@ 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 {
FiArrowLeft, FiUser, FiLock, FiCamera, FiImage,
FiMail, FiAtSign, FiShield, FiAlertTriangle, FiTrash2,
} from "react-icons/fi";
import { useAuth } from "@/hooks/useAuth";
import { useTurnstile } from "@/hooks/useTurnstile";
import { privateApi } from "@/api/privateClient";
import { mediaUrl } from "@/utils/mediaUrl";
import Avatar from "@/components/ui/Avatar";
@@ -13,7 +17,7 @@ import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
type Tab = "profile" | "security";
type Tab = "profile" | "account" | "security";
interface ProfileForm {
first_name: string;
@@ -22,10 +26,104 @@ interface ProfileForm {
phone_number: string;
}
interface PasswordForm {
current_password: string;
new_password: string;
confirm_password: string;
interface UsernameForm { new_username: string }
interface EmailForm { current_password: string; new_email: string }
interface PasswordForm { current_password: string; new_password: string; confirm_password: string }
// ── Reusable section header ─────────────────────────────────────
function Section({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="mb-4">
<h2 className="text-sm font-semibold text-brand-text">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-brand-text/50">{subtitle}</p>}
</div>
);
}
// ── Feedback row ────────────────────────────────────────────────
function Success({ msg }: { msg: string }) {
return (
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
{msg}
</div>
);
}
// ── Delete account confirmation form (own component so useTurnstile mounts fresh) ──
function DeleteAccountForm({
inputClass,
onCancel,
onDeleted,
}: {
inputClass: string;
onCancel: () => void;
onDeleted: () => void;
}) {
const navigate = useNavigate();
const { refreshUser } = useAuth() as any;
const [password, setPassword] = useState("");
const [error, setError] = useState<string>();
const [submitting, setSubmitting] = useState(false);
const { containerRef, token, enabled } = useTurnstile();
async function handleDelete() {
if (!password || !token) return;
setError(undefined);
setSubmitting(true);
try {
await privateApi.post("/api/account/delete/", {
current_password: password,
turnstile_token: token,
});
await refreshUser?.();
navigate("/");
onDeleted();
} catch (err: any) {
setError(
err?.response?.data?.detail ??
err?.response?.data?.current_password ??
"Chyba. Zkuste to znovu."
);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-brand-text/60">
Pro potvrzení zadejte své heslo. Účet bude okamžitě deaktivován a budete odhlášeni.
</p>
{error && <p className="text-xs text-red-400">{error}</p>}
<input
type="password"
className={inputClass + " border-red-500/30 focus:border-red-400"}
placeholder="Vaše heslo"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
{enabled && <div ref={containerRef} className="flex justify-center" />}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 rounded-xl border border-brand-lines/20 bg-brand-bgLight/30 px-3 py-2 text-xs font-medium text-brand-text/70 hover:bg-brand-bgLight/50 transition-colors"
>
Zrušit
</button>
<button
type="button"
onClick={handleDelete}
disabled={submitting || !password || !token}
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl bg-red-600/80 px-3 py-2 text-xs font-semibold text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? <Spinner size={12} /> : <FiTrash2 size={12} />}
{submitting ? "Mažu…" : "Potvrdit smazání"}
</button>
</div>
</div>
);
}
export default function AccountSettingsPage() {
@@ -35,9 +133,13 @@ export default function AccountSettingsPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("profile");
// ── Profile form ──────────────────────────────────────────────
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";
// ── Profile ───────────────────────────────────────────────────
const [profileSuccess, setProfileSuccess] = useState(false);
const [profileRootError, setProfileRootError] = useState<string>();
const [profileError, setProfileError] = useState<string>();
const profileForm = useForm<ProfileForm>({
defaultValues: {
first_name: user?.first_name ?? "",
@@ -46,27 +148,26 @@ export default function AccountSettingsPage() {
phone_number: user?.phone_number ?? "",
},
});
const { register: regProfile, handleSubmit: handleProfile, formState: { isSubmitting: profileSubmitting } } = profileForm;
const { register: rP, handleSubmit: hP, formState: { isSubmitting: pSubmit } } = profileForm;
async function onProfileSubmit(values: ProfileForm) {
setProfileRootError(undefined);
setProfileError(undefined);
setProfileSuccess(false);
try {
await privateApi.patch(`/api/account/users/${user.id}/`, values);
setProfileSuccess(true);
await queryClient.invalidateQueries({ queryKey: ["account"] });
if (refreshUser) await refreshUser();
await refreshUser?.();
} catch (err) {
setProfileRootError(applyServerErrors(profileForm, err));
setProfileError(applyServerErrors(profileForm, err));
}
}
// ── Avatar upload ─────────────────────────────────────────────
const avatarInputRef = useRef<HTMLInputElement>(null);
// ── Avatar ────────────────────────────────────────────────────
const avatarRef = useRef<HTMLInputElement>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
async function handleAvatar(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setAvatarPreview(URL.createObjectURL(file));
@@ -76,19 +177,15 @@ export default function AccountSettingsPage() {
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 = "";
}
await refreshUser?.();
} finally { setAvatarUploading(false); e.target.value = ""; }
}
// ── Banner upload ─────────────────────────────────────────────
const bannerInputRef = useRef<HTMLInputElement>(null);
// ── Banner ────────────────────────────────────────────────────
const bannerRef = useRef<HTMLInputElement>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [bannerUploading, setBannerUploading] = useState(false);
async function handleBannerChange(e: React.ChangeEvent<HTMLInputElement>) {
async function handleBanner(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBannerPreview(URL.createObjectURL(file));
@@ -98,26 +195,84 @@ export default function AccountSettingsPage() {
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 = "";
await refreshUser?.();
} finally { setBannerUploading(false); e.target.value = ""; }
}
// ── Username change ───────────────────────────────────────────
const [usernameSuccess, setUsernameSuccess] = useState(false);
const [usernameError, setUsernameError] = useState<string>();
const [daysRemaining, setDaysRemaining] = useState<number | null>(null);
const usernameForm = useForm<UsernameForm>({ defaultValues: { new_username: "" } });
const { register: rU, handleSubmit: hU, formState: { isSubmitting: uSubmit }, reset: resetUsername } = usernameForm;
const { containerRef: tUsernameRef, token: tUsernameToken, enabled: turnstileEnabled, reset: resetTUsername } = useTurnstile();
async function onUsernameSubmit(values: UsernameForm) {
if (!tUsernameToken) return;
setUsernameError(undefined);
setUsernameSuccess(false);
setDaysRemaining(null);
try {
await privateApi.post("/api/account/change-username/", {
new_username: values.new_username,
turnstile_token: tUsernameToken,
});
setUsernameSuccess(true);
resetUsername();
await refreshUser?.();
} catch (err: any) {
const days = err?.response?.data?.days_remaining;
if (days) setDaysRemaining(days);
setUsernameError(applyServerErrors(usernameForm, err));
resetTUsername();
}
}
// ── Password form ─────────────────────────────────────────────
// ── Email change ──────────────────────────────────────────────
const [emailSuccess, setEmailSuccess] = useState(false);
const [emailError, setEmailError] = useState<string>();
const emailForm = useForm<EmailForm>({ defaultValues: { current_password: "", new_email: "" } });
const { register: rE, handleSubmit: hE, formState: { isSubmitting: eSubmit }, reset: resetEmail } = emailForm;
const { containerRef: tEmailRef, token: tEmailToken, reset: resetTEmail } = useTurnstile();
async function onEmailSubmit(values: EmailForm) {
if (!tEmailToken) return;
setEmailError(undefined);
setEmailSuccess(false);
try {
await privateApi.post("/api/account/change-email/", {
current_password: values.current_password,
new_email: values.new_email,
turnstile_token: tEmailToken,
});
setEmailSuccess(true);
resetEmail();
} catch (err) {
setEmailError(applyServerErrors(emailForm, err));
resetTEmail();
}
}
// ── Account deletion ─────────────────────────────────────────
const [deleteOpen, setDeleteOpen] = useState(false);
// ── Password change ───────────────────────────────────────────
const [passwordSuccess, setPasswordSuccess] = useState(false);
const [passwordRootError, setPasswordRootError] = useState<string>();
const [passwordError, setPasswordError] = 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;
const {
register: rPw, handleSubmit: hPw,
formState: { isSubmitting: pwSubmit },
reset: resetPassword, setError: setPwError,
} = passwordForm;
async function onPasswordSubmit(values: PasswordForm) {
setPasswordRootError(undefined);
setPasswordError(undefined);
setPasswordSuccess(false);
if (values.new_password !== values.confirm_password) {
setPasswordError("confirm_password", { message: t("accountSettings.passwordMismatch") });
setPwError("confirm_password", { message: t("accountSettings.passwordMismatch") });
return;
}
try {
@@ -128,7 +283,7 @@ export default function AccountSettingsPage() {
setPasswordSuccess(true);
resetPassword();
} catch (err) {
setPasswordRootError(applyServerErrors(passwordForm, err));
setPasswordError(applyServerErrors(passwordForm, err));
}
}
@@ -136,14 +291,20 @@ export default function AccountSettingsPage() {
const avatarSrc = avatarPreview ?? mediaUrl((user as any)?.avatar);
const bannerSrc = bannerPreview ?? mediaUrl((user as any)?.banner);
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "profile", label: "Profil", icon: <FiUser size={15} /> },
{ id: "account", label: "Účet", icon: <FiAtSign size={15} /> },
{ id: "security", label: "Zabezpečení", icon: <FiShield size={15} /> },
];
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",
"flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors w-full text-left",
active
? "bg-brand-lines/15 text-brand-text"
: "text-brand-text/55 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">
@@ -154,177 +315,297 @@ export default function AccountSettingsPage() {
>
<FiArrowLeft size={20} />
</button>
<h1 className="text-lg font-bold text-brand-text">{t("accountSettings.title")}</h1>
<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} /> {t("accountSettings.tabProfile")}
</button>
<button type="button" className={tabClass(tab === "security")} onClick={() => setTab("security")}>
<FiLock size={16} /> {t("accountSettings.tabSecurity")}
</button>
<div className="flex">
{/* Sidebar */}
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-2">
{tabs.map(({ id, label, icon }) => (
<button key={id} type="button" className={tabClass(tab === id)} onClick={() => setTab(id)}>
{icon} {label}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1 p-6 max-w-lg">
<div className="flex-1 p-6 max-w-lg flex flex-col gap-8">
{/* ── Profile tab ── */}
{/* ── Profil ── */}
{tab === "profile" && (
<div className="flex flex-col gap-6">
{/* Appearance: banner + avatar */}
<div>
<div className="text-sm font-semibold text-brand-text mb-3">{t("accountSettings.appearanceLabel")}</div>
<>
<Section title="Fotografie" subtitle="Profilová fotka a banner viditelné ostatním uživatelům." />
{/* 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" />
{/* Banner + avatar */}
<div className="relative mb-6">
<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={() => bannerRef.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/20 transition-colors group-hover:bg-black/40">
{bannerUploading ? <Spinner size={22} /> : (
<div className="flex flex-col items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
<FiImage size={20} className="text-white" />
<span className="text-xs text-white/90">Změnit banner</span>
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/40">
{bannerUploading ? (
<Spinner size={22} />
) : (
<div className="flex flex-col items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100">
<FiImage size={20} className="text-white" />
<span className="text-xs text-white/90">{t("accountSettings.changeBanner")}</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>
<input ref={bannerRef} type="file" accept="image/*" className="hidden" onChange={handleBanner} />
<p className="text-xs text-brand-text/40">{t("accountSettings.fileHint")}</p>
<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={() => avatarRef.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"
>
<FiCamera size={11} />
</button>
</div>
<input ref={avatarRef} type="file" accept="image/*" className="hidden" onChange={handleAvatar} />
</div>
</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">
{t("accountSettings.profileSaved")}
</div>
)}
<p className="text-xs text-brand-text/35 -mt-4">Maximální velikost souboru: 5 MB. Formáty: JPG, PNG, WebP.</p>
<hr className="border-brand-lines/10" />
<Section title="Základní informace" subtitle="Zobrazené jméno a kontaktní údaje." />
<form onSubmit={hP(onProfileSubmit)} className="flex flex-col gap-4">
<FormErrorBanner message={profileError} />
{profileSuccess && <Success msg="Profil byl uložen." />}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.firstNameLabel")}</label>
<input className={inputClass} placeholder={t("accountSettings.firstNamePlaceholder")} {...regProfile("first_name")} />
<label className="mb-1 block text-xs font-medium text-brand-text/65">Jméno</label>
<input className={inputClass} placeholder="Jan" {...rP("first_name")} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.lastNameLabel")}</label>
<input className={inputClass} placeholder={t("accountSettings.lastNamePlaceholder")} {...regProfile("last_name")} />
<label className="mb-1 block text-xs font-medium text-brand-text/65">Příjmení</label>
<input className={inputClass} placeholder="Novák" {...rP("last_name")} />
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.usernameLabel")}</label>
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.username ?? ""} readOnly disabled />
<label className="mb-1 block text-xs font-medium text-brand-text/65">Město</label>
<input className={inputClass} placeholder="Praha" {...rP("city")} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.emailLabel")}</label>
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.email ?? ""} readOnly disabled />
<label className="mb-1 block text-xs font-medium text-brand-text/65">Telefon</label>
<input className={inputClass} placeholder="+420 ..." {...rP("phone_number")} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.cityLabel")}</label>
<input className={inputClass} placeholder={t("accountSettings.cityPlaceholder")} {...regProfile("city")} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.phoneLabel")}</label>
<input className={inputClass} placeholder="+420 ..." {...regProfile("phone_number")} />
</div>
<Button type="submit" disabled={profileSubmitting} leftIcon={profileSubmitting ? <Spinner size={14} /> : undefined}>
{profileSubmitting ? t("accountSettings.saving") : t("accountSettings.saveProfile")}
<Button type="submit" disabled={pSubmit} leftIcon={pSubmit ? <Spinner size={14} /> : undefined}>
{pSubmit ? "Ukládám…" : "Uložit profil"}
</Button>
</form>
</div>
</>
)}
{/* ── Security tab ── */}
{/* ── Účet ── */}
{tab === "account" && (
<>
{/* Username */}
<div>
<Section
title="Uživatelské jméno"
subtitle={`Aktuálně: @${user?.username} · Lze měnit jednou za 30 dní.`}
/>
<form onSubmit={hU(onUsernameSubmit)} className="flex flex-col gap-3">
<FormErrorBanner message={usernameError} />
{daysRemaining !== null && (
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-sm text-amber-400">
Příliš brzy. Zkuste to za {daysRemaining} {daysRemaining === 1 ? "den" : "dní"}.
</div>
)}
{usernameSuccess && <Success msg="Uživatelské jméno bylo změněno." />}
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/65">Nové uživatelské jméno</label>
<input
className={inputClass}
placeholder={user?.username}
{...rU("new_username", { required: true, minLength: 3 })}
/>
{usernameForm.formState.errors.new_username && (
<p className="mt-1 text-xs text-red-400">Minimálně 3 znaky.</p>
)}
</div>
{turnstileEnabled && <div ref={tUsernameRef} className="flex justify-center" />}
<Button
type="submit"
variant="secondary"
disabled={uSubmit || !tUsernameToken}
leftIcon={uSubmit ? <Spinner size={14} /> : undefined}
>
{uSubmit ? "Ukládám…" : "Změnit jméno"}
</Button>
</form>
</div>
<hr className="border-brand-lines/10" />
{/* Email */}
<div>
<Section
title="E-mailová adresa"
subtitle={`Aktuálně: ${user?.email} · Po odeslání potvrďte novou adresu kliknutím na odkaz v e-mailu.`}
/>
<form onSubmit={hE(onEmailSubmit)} className="flex flex-col gap-4">
<FormErrorBanner message={emailError} />
{emailSuccess && <Success msg="Ověřovací e-mail byl odeslán na novou adresu." />}
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/65">Stávající heslo</label>
<input
className={inputClass}
placeholder="••••••••"
autoComplete="current-password"
{...rE("current_password", { required: true })}
type="password"
/>
{emailForm.formState.errors.current_password && (
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/65">Nová e-mailová adresa</label>
<input
className={inputClass}
placeholder="nova@adresa.cz"
autoComplete="email"
inputMode="email"
{...rE("new_email", { required: true })}
type="email"
/>
{emailForm.formState.errors.new_email && (
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
)}
</div>
{turnstileEnabled && <div ref={tEmailRef} className="flex justify-center" />}
<Button
type="submit"
variant="secondary"
disabled={eSubmit || !tEmailToken}
leftIcon={eSubmit ? <Spinner size={14} /> : <FiMail size={14} />}
>
{eSubmit ? "Odesílám…" : "Odeslat ověřovací e-mail"}
</Button>
</form>
</div>
</>
)}
{/* ── Zabezpečení ── */}
{tab === "security" && (
<form onSubmit={handlePassword(onPasswordSubmit)} className="flex flex-col gap-4">
<div className="text-sm font-semibold text-brand-text">{t("accountSettings.changePassword")}</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">
{t("accountSettings.passwordChanged")}
<div className="flex flex-col gap-8">
{/* Password */}
<div>
<Section title="Změna hesla" subtitle="Heslo musí mít alespoň 8 znaků, velké i malé písmeno a číslici." />
<form onSubmit={hPw(onPasswordSubmit)} className="flex flex-col gap-4">
<FormErrorBanner message={passwordError} />
{passwordSuccess && <Success msg="Heslo bylo úspěšně změněno." />}
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/65">Stávající heslo</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
autoComplete="current-password"
{...rPw("current_password", { required: true })}
/>
{passwordForm.formState.errors.current_password && (
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/65">Nové heslo</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
autoComplete="new-password"
{...rPw("new_password", { required: true })}
/>
{passwordForm.formState.errors.new_password && (
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/65">Potvrdit nové heslo</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
autoComplete="new-password"
{...rPw("confirm_password", { required: true })}
/>
{passwordForm.formState.errors.confirm_password && (
<p className="mt-1 text-xs text-red-400">
{passwordForm.formState.errors.confirm_password.message ?? "Povinné pole."}
</p>
)}
</div>
<Button
type="submit"
disabled={pwSubmit}
leftIcon={pwSubmit ? <Spinner size={14} /> : <FiLock size={14} />}
>
{pwSubmit ? "Ukládám…" : "Změnit heslo"}
</Button>
</form>
</div>
{/* Danger zone */}
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 p-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<FiAlertTriangle size={15} className="text-red-400 shrink-0" />
<span className="text-sm font-semibold text-red-400">Nebezpečná zóna</span>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.currentPasswordLabel")}</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
{...regPassword("current_password", { required: t("accountSettings.required") })}
/>
{passwordForm.formState.errors.current_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.current_password.message}</p>
{!deleteOpen ? (
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-brand-text/50">
Trvale deaktivuje váš účet. Tato akce je nevratná.
</p>
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="shrink-0 flex items-center gap-1.5 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/20 transition-colors"
>
<FiTrash2 size={13} /> Smazat účet
</button>
</div>
) : (
<DeleteAccountForm
inputClass={inputClass}
onCancel={() => setDeleteOpen(false)}
onDeleted={() => setDeleteOpen(false)}
/>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.newPasswordLabel")}</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
{...regPassword("new_password", { required: t("accountSettings.required") })}
/>
{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">{t("accountSettings.confirmPasswordLabel")}</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
{...regPassword("confirm_password", { required: t("accountSettings.required") })}
/>
{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 ? t("accountSettings.saving") : t("accountSettings.changePasswordBtn")}
</Button>
</form>
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { apiAccountConfirmEmailChangeRetrieve } from "@/api/generated/private/account/account";
import { useAuth } from "@/hooks/useAuth";
import Spinner from "@/components/ui/Spinner";
type Status = "loading" | "success" | "error";
export default function ConfirmEmailChangePage() {
const { uidb64, token } = useParams<{ uidb64: string; token: string }>();
const { refreshUser } = useAuth();
const [status, setStatus] = useState<Status>("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!uidb64 || !token) {
setStatus("error");
setMessage("Neplatný odkaz.");
return;
}
apiAccountConfirmEmailChangeRetrieve(uidb64, token)
.then(() => {
setStatus("success");
setMessage("E-mail byl úspěšně změněn.");
refreshUser?.();
})
.catch((err: any) => {
setStatus("error");
setMessage(err?.response?.data?.error ?? "Odkaz je neplatný nebo expirovaný.");
});
}, [uidb64, token]);
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="glass rounded-2xl p-10 text-center max-w-sm w-full flex flex-col items-center gap-4">
{status === "loading" && <Spinner size={32} />}
{status === "success" && (
<>
<span className="text-5xl"></span>
<h1 className="text-xl font-bold text-brand-text">E-mail změněn</h1>
<p className="text-sm text-brand-text/60">{message}</p>
<Link to="/social/account/settings" className="text-sm text-brand-accent hover:underline">
Zpět na nastavení
</Link>
</>
)}
{status === "error" && (
<>
<span className="text-5xl"></span>
<h1 className="text-xl font-bold text-brand-text">Chyba</h1>
<p className="text-sm text-brand-text/60">{message}</p>
<Link to="/social/account/settings" className="text-sm text-brand-accent hover:underline">
Zpět na nastavení
</Link>
</>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,16 @@
const _backendHost = (() => {
try {
return new URL(import.meta.env.VITE_BACKEND_URL || "").host;
} catch {
return "";
}
})();
/**
* Normalises a media URL so it always resolves through the current origin
* (Vite dev proxy → backend, or nginx in production).
*
* - Full URLs (http/https): strip the origin, keep only the path.
* - Relative paths without a leading slash: add one.
* Normalises a media URL:
* - External hosts (S3, CDN): returned as-is.
* - Backend / same-origin URLs: strip origin so the request goes through
* the Vite dev proxy or nginx in production.
* - blob: / data: URLs: returned unchanged.
* - null / undefined / empty: returns null.
*/
@@ -11,11 +18,12 @@ export function mediaUrl(src: string | null | undefined): string | null {
if (!src) return null;
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
try {
// Full URL — strip origin so the request goes through the proxy/nginx
const url = new URL(src);
const isLocal =
url.host === window.location.host || url.host === _backendHost;
if (!isLocal) return src; // S3 / CDN — keep full URL
return url.pathname + (url.search || "");
} catch {
// Already a relative path
return src.startsWith("/") ? src : `/${src}`;
}
}