done last commit before merging - fixed media URLSs S3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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
|
||||
|
||||
69
frontend/src/components/common/TopBanner.tsx
Normal file
69
frontend/src/components/common/TopBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
transition: all 1s ease-out;
|
||||
}
|
||||
.content-moveup{
|
||||
transform: translateY(-70%);
|
||||
transform: translateY(-80%);
|
||||
}
|
||||
.content-moveup-index { z-index: 2 !important; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
62
frontend/src/pages/social/account/ConfirmEmailChangePage.tsx
Normal file
62
frontend/src/pages/social/account/ConfirmEmailChangePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user