posts are done

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

View File

@@ -28,6 +28,8 @@ import HubsPage from "./pages/social/HubsPage";
import HubPage from "./pages/social/HubPage";
import ProfilePage from "./pages/social/ProfilePage";
import UserProfilePage from "./pages/social/UserProfilePage";
import SavedPage from "./pages/social/SavedPage";
import AccountSettingsPage from "./pages/social/AccountSettingsPage";
import ChatsIndexPage from "./pages/social/chat/ChatsPage";
import ChatRoomPage from "./pages/social/chat/ChatRoomPage";
@@ -63,7 +65,9 @@ export default function App() {
<Route path="hubs" element={<HubsPage />} />
<Route path="hub/:id" element={<HubPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="profile/:id" element={<UserProfilePage />} />
<Route path="profile/:username" element={<UserProfilePage />} />
<Route path="saved" element={<SavedPage />} />
<Route path="account/settings" element={<AccountSettingsPage />} />
<Route path="chats" element={<ChatLayout />}>
<Route index element={<ChatsIndexPage />} />
<Route path=":chatId" element={<ChatRoomPage />} />

View File

@@ -0,0 +1,9 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export type ApiSocialPostsSaveCreate200 = {
saved?: boolean;
};

View File

@@ -0,0 +1,23 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export type ApiSocialPostsSavedListParams = {
author?: number;
hub?: number;
/**
* Which field to use when ordering the results.
*/
ordering?: string;
/**
* A page number within the paginated result set.
*/
page?: number;
reply_to?: number;
/**
* A search term.
*/
search?: string;
};

View File

@@ -29,6 +29,8 @@ export * from "./apiSocialMessagesListParams";
export * from "./apiSocialPostsFeedListParams";
export * from "./apiSocialPostsListParams";
export * from "./apiSocialPostsMediaCreateBody";
export * from "./apiSocialPostsSaveCreate200";
export * from "./apiSocialPostsSavedListParams";
export * from "./apiZasilkovnaShipmentsListParams";
export * from "./authorMinimal";
export * from "./callback";

View File

@@ -23,4 +23,6 @@ export interface PatchedPost {
readonly vote_score?: string;
readonly user_vote?: string;
readonly reply_count?: number;
readonly is_saved?: string;
readonly save_count?: string;
}

View File

@@ -23,4 +23,6 @@ export interface Post {
readonly vote_score: string;
readonly user_vote: string;
readonly reply_count: number;
readonly is_saved: string;
readonly save_count: string;
}

View File

@@ -23,6 +23,8 @@ import type {
ApiSocialPostsFeedListParams,
ApiSocialPostsListParams,
ApiSocialPostsMediaCreateBody,
ApiSocialPostsSaveCreate200,
ApiSocialPostsSavedListParams,
PaginatedPostList,
PatchedPost,
Post,
@@ -806,6 +808,94 @@ export const useApiSocialPostsMediaCreate = <
queryClient,
);
};
/**
* Saves the post for the current user, or unsaves it if already saved. Returns `{saved: true/false}`.
* @summary Toggle save on a post
*/
export const apiSocialPostsSaveCreate = (
id: number,
post: NonReadonly<Post>,
signal?: AbortSignal,
) => {
return privateMutator<ApiSocialPostsSaveCreate200>({
url: `/api/social/posts/${id}/save/`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: post,
signal,
});
};
export const getApiSocialPostsSaveCreateMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
TContext
> => {
const mutationKey = ["apiSocialPostsSaveCreate"];
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 apiSocialPostsSaveCreate>>,
{ id: number; data: NonReadonly<Post> }
> = (props) => {
const { id, data } = props ?? {};
return apiSocialPostsSaveCreate(id, data);
};
return { mutationFn, ...mutationOptions };
};
export type ApiSocialPostsSaveCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>
>;
export type ApiSocialPostsSaveCreateMutationBody = NonReadonly<Post>;
export type ApiSocialPostsSaveCreateMutationError = unknown;
/**
* @summary Toggle save on a post
*/
export const useApiSocialPostsSaveCreate = <
TError = unknown,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
TContext
> => {
return useMutation(
getApiSocialPostsSaveCreateMutationOptions(options),
queryClient,
);
};
/**
* Attaches an existing hub tag to the post. The tag must belong to the same hub as the post. Any authenticated hub member can attach tags.
* @summary Attach a tag to a post
@@ -1229,3 +1319,162 @@ export function useApiSocialPostsFeedList<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List posts saved by the current user
*/
export const apiSocialPostsSavedList = (
params?: ApiSocialPostsSavedListParams,
signal?: AbortSignal,
) => {
return privateMutator<PaginatedPostList>({
url: `/api/social/posts/saved/`,
method: "GET",
params,
signal,
});
};
export const getApiSocialPostsSavedListQueryKey = (
params?: ApiSocialPostsSavedListParams,
) => {
return [`/api/social/posts/saved/`, ...(params ? [params] : [])] as const;
};
export const getApiSocialPostsSavedListQueryOptions = <
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getApiSocialPostsSavedListQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
> = ({ signal }) => apiSocialPostsSavedList(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ApiSocialPostsSavedListQueryResult = NonNullable<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
>;
export type ApiSocialPostsSavedListQueryError = unknown;
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params: undefined | ApiSocialPostsSavedListParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List posts saved by the current user
*/
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getApiSocialPostsSavedListQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -23,4 +23,6 @@ export interface PatchedPost {
readonly vote_score?: string;
readonly user_vote?: string;
readonly reply_count?: number;
readonly is_saved?: string;
readonly save_count?: string;
}

View File

@@ -23,4 +23,6 @@ export interface Post {
readonly vote_score: string;
readonly user_vote: string;
readonly reply_count: number;
readonly is_saved: string;
readonly save_count: string;
}

View File

@@ -1,31 +1,89 @@
import axios, { type AxiosRequestConfig } from "axios";
import { AUTH_FLAG } from "@/context/AuthContext";
// použij tohle pro API vyžadující autentizaci
export const privateApi = axios.create({
withCredentials: true, // potřebuje HttpOnly cookies
withCredentials: true,
baseURL: '',
});
// Set baseURL at runtime (using Function to hide from orval's esbuild)
try {
const getEnv = new Function('return import.meta.env.VITE_BACKEND_URL');
privateApi.defaults.baseURL = getEnv() || "http://localhost:8000";
} catch {
privateApi.defaults.baseURL = "http://localhost:8000";
}
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: any) => void;
}> = [];
const processQueue = (error: any = null) => {
failedQueue.forEach((promise) => {
if (error) {
promise.reject(error);
} else {
promise.resolve();
}
});
failedQueue = [];
};
privateApi.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 400 && error.response?.data) {
const data = error.response.data;
if (typeof data === 'object' && !Array.isArray(data)) {
const firstKey = Object.keys(data)[0];
if (firstKey && Array.isArray(data[firstKey]) && data[firstKey].length > 0) {
error.message = data[firstKey][0];
}
}
}
if (error.response?.status === 401 && !original._retry) {
if (original.url?.includes("/api/account/logout/")) {
return Promise.reject(error);
}
if (error.response?.data?.code === "user_not_found") {
processQueue(error);
isRefreshing = false;
localStorage.removeItem(AUTH_FLAG);
window.location.href = "/social/login";
return Promise.reject(error);
}
if (original.url?.includes("/api/account/token/refresh/")) {
processQueue(error);
isRefreshing = false;
localStorage.removeItem(AUTH_FLAG);
try {
await privateApi.post("/api/account/logout/");
} catch {
// cookies may already be expired
}
window.location.href = "/social/login";
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => privateApi(original))
.catch((err) => Promise.reject(err));
}
original._retry = true;
isRefreshing = true;
try {
await privateApi.post("/api/account/token/refresh/");
processQueue();
return privateApi(original);
} catch {
// optional: logout
} catch (refreshError) {
processQueue(refreshError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
@@ -33,15 +91,10 @@ privateApi.interceptors.response.use(
}
);
export const privateMutator = async <T>(
config: AxiosRequestConfig
): Promise<T> => {
// If sending FormData, remove Content-Type header to let axios set it with boundary
export const privateMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
if (config.data instanceof FormData) {
delete config.headers?.['Content-Type'];
}
const response = await privateApi.request<T>(config);
return response.data;
};
};

View File

@@ -1,23 +1,46 @@
import axios, { type AxiosRequestConfig } from "axios";
import { AUTH_FLAG } from "@/context/AuthContext";
// použij tohle pro veřejné API nevyžadující autentizaci
export const publicApi = axios.create({
withCredentials: false, // veřejné API NEPOSÍLÁ cookies
withCredentials: true,
baseURL: '',
});
// Set baseURL at runtime (using Function to hide from orval's esbuild)
try {
const getEnv = new Function('return import.meta.env.VITE_BACKEND_URL');
publicApi.defaults.baseURL = getEnv() || "http://localhost:8000";
} catch {
publicApi.defaults.baseURL = "http://localhost:8000";
}
publicApi.interceptors.response.use(
(res) => res,
(error) => {
const url = error.config?.url ?? '';
if (
error.response?.status === 401 &&
error.response?.data?.code === "user_not_found" &&
!url.includes("/api/account/logout/")
) {
localStorage.removeItem(AUTH_FLAG);
window.location.href = "/social/login";
}
return Promise.reject(error);
}
);
const pendingRequests = new Map();
// ⬇⬇⬇ TOHLE JE TEN MUTATOR ⬇⬇⬇
export const publicMutator = async <T>(
config: AxiosRequestConfig
): Promise<T> => {
const response = await publicApi.request<T>(config);
return response.data;
};
export const publicMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
const requestKey = `${config.method}_${config.url}_${JSON.stringify(config.data || '')}`;
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey);
}
const requestPromise = (async () => {
try {
const response = await publicApi.request<T>(config);
return response.data;
} finally {
pendingRequests.delete(requestKey);
}
})();
pendingRequests.set(requestKey, requestPromise);
return requestPromise;
};

View File

@@ -1,46 +1,239 @@
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { FiX, FiChevronLeft, FiChevronRight, FiFile, FiDownload } from "react-icons/fi";
import type { PostContent } from "@/api/generated/private/models/postContent";
import { mediaUrl } from "@/utils/mediaUrl";
interface Props {
items: readonly PostContent[];
}
export default function MediaGallery({ items }: Props) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Grid shows first 4; lightbox navigates all
const visible = items.slice(0, 4);
const overflow = items.length - visible.length;
const isOpen = lightboxIndex !== null;
const prev = useCallback(() => {
setLightboxIndex((i) => (i !== null ? (i - 1 + items.length) % items.length : null));
}, [items.length]);
const next = useCallback(() => {
setLightboxIndex((i) => (i !== null ? (i + 1) % items.length : null));
}, [items.length]);
const close = useCallback(() => setLightboxIndex(null), []);
useEffect(() => {
if (!isOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen, close, prev, next]);
if (!items?.length) return null;
const layoutClass =
items.length === 1
? "grid-cols-1"
: items.length === 2
? "grid-cols-2"
: "grid-cols-2";
const layoutClass = visible.length === 1 ? "grid-cols-1" : "grid-cols-2";
return (
<div className={`mt-3 grid ${layoutClass} gap-2 overflow-hidden rounded-xl border border-brand-lines/15`}>
{items.map((it) => (
<MediaItem key={it.id} item={it} />
))}
</div>
<>
<div className={`mt-3 grid ${layoutClass} overflow-hidden rounded-xl border border-brand-lines/15`}>
{visible.map((it, i) => (
<MediaItem
key={it.id}
item={it}
overflowCount={i === 3 && overflow > 0 ? overflow : 0}
onOpen={() => setLightboxIndex(i)}
/>
))}
</div>
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={close}
>
{/* Close */}
<button
type="button"
onClick={close}
className="absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
aria-label="Zavřít"
>
<FiX size={20} />
</button>
{/* Prev */}
{items.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
aria-label="Předchozí"
>
<FiChevronLeft size={24} />
</button>
)}
{/* Content */}
<LightboxContent item={items[lightboxIndex!]} />
{/* Next */}
{items.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
aria-label="Další"
>
<FiChevronRight size={24} />
</button>
)}
{/* Dots */}
{items.length > 1 && (
<div
className="absolute bottom-5 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{items.map((_: PostContent, i: number) => (
<button
key={i}
type="button"
onClick={() => setLightboxIndex(i)}
aria-label={`Položka ${i + 1}`}
className={[
"h-2 rounded-full transition-all duration-200",
i === lightboxIndex ? "w-5 bg-white" : "w-2 bg-white/40 hover:bg-white/70",
].join(" ")}
/>
))}
</div>
)}
</div>,
document.body,
)}
</>
);
}
function MediaItem({ item }: { item: PostContent }) {
const url = item.file ?? "";
function LightboxContent({ item }: { item: PostContent }) {
const url = mediaUrl(item.file) ?? "";
const mime = item.mime_type ?? "";
if (mime.startsWith("video/")) {
return (
<video
src={url}
controls
className="w-full max-h-[480px] object-cover bg-black"
className="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
);
}
if (mime.startsWith("image/")) {
return (
<img
src={url}
alt={item.alt_text ?? ""}
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
);
}
const filename = url.split("/").pop() ?? "soubor";
return (
<img
src={url}
alt={item.alt_text ?? ""}
className="w-full max-h-[480px] object-cover bg-brand-bg/60"
loading="lazy"
/>
<a
href={url}
download
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex flex-col items-center gap-4 rounded-2xl border border-white/20 bg-white/10 px-10 py-8 text-white hover:bg-white/20 transition-colors"
>
<FiFile size={48} />
<span className="max-w-xs truncate text-sm">{filename}</span>
<FiDownload size={20} className="opacity-70" />
</a>
);
}
function MediaItem({
item,
onOpen,
overflowCount = 0,
}: {
item: PostContent;
onOpen: () => void;
overflowCount?: number;
}) {
const url = mediaUrl(item.file) ?? "";
const mime = item.mime_type ?? "";
const overlay = overflowCount > 0 ? (
<div
className="absolute inset-0 flex items-center justify-center bg-black/55 cursor-pointer"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
>
<span className="text-3xl font-semibold text-white">+{overflowCount}</span>
</div>
) : null;
if (mime.startsWith("video/")) {
return (
<div className="relative aspect-square w-full bg-black cursor-pointer" onClick={(e) => { e.stopPropagation(); onOpen(); }}>
<video src={url} muted playsInline preload="metadata" className="h-full w-full object-cover pointer-events-none" />
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/20">
<svg viewBox="0 0 24 24" fill="white" className="h-5 w-5 translate-x-0.5"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
{overlay}
</div>
);
}
if (mime.startsWith("image/")) {
return (
<div className="relative aspect-square w-full bg-brand-bg/60">
<img
src={url}
alt={item.alt_text ?? ""}
className="h-full w-full cursor-zoom-in object-cover transition-opacity hover:opacity-90"
loading="lazy"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
/>
{overlay}
</div>
);
}
const filename = url.split("/").pop() ?? "soubor";
return (
<div className="relative aspect-square w-full p-2">
<a
href={url}
download
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl border border-brand-lines/20 bg-brand-bgLight/30 px-3 hover:bg-brand-lines/10 transition-colors"
>
<FiFile size={28} className="text-brand-text/40" />
<span className="w-full truncate text-center text-[11px] text-brand-text/60 leading-tight">{filename}</span>
<FiDownload size={13} className="text-brand-text/30" />
</a>
{overlay}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useRef, useState, useEffect } from "react";
import { FiTrash2, FiMoreHorizontal } from "react-icons/fi";
import type { Post } from "@/api/generated/private/models/post";
import Avatar from "@/components/ui/Avatar";
@@ -21,11 +22,13 @@ interface AuthorDetail {
avatar: string | null;
}
type EnrichedPost = Post & {
type EnrichedPost = Omit<Post, 'vote_score' | 'user_vote' | 'is_saved' | 'save_count'> & {
author_detail?: AuthorDetail;
vote_score?: number;
user_vote?: -1 | 0 | 1;
reply_count?: number;
is_saved?: boolean;
save_count?: number;
};
interface Props {
@@ -66,12 +69,26 @@ export default function Post({
function open(e: React.MouseEvent) {
if (!clickable) return;
const target = e.target as HTMLElement;
if (target.closest("button, a")) return;
if (target.closest("button, a, [role='button']")) return;
navigate(`/social/post/${post.id}`);
}
const canDelete = canDeletePost(user, post);
const canEdit = canEditPost(user, post);
const canDelete = canDeletePost(user, post as unknown as Post);
const canEdit = canEditPost(user, post as unknown as Post);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
function onClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [menuOpen]);
return (
<article
@@ -84,7 +101,7 @@ export default function Post({
>
<div className="flex gap-3">
<Link
to={`/social/profile/${author?.id ?? post.author}`}
to={`/social/profile/${author?.username ?? post.author}`}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
@@ -99,7 +116,7 @@ export default function Post({
<header className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0 text-sm">
<Link
to={`/social/profile/${author?.id ?? post.author}`}
to={`/social/profile/${author?.username ?? post.author}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold text-brand-text hover:underline"
>
@@ -121,19 +138,25 @@ export default function Post({
</div>
{(canDelete || canEdit) && (
<div className="flex items-center gap-1">
{canDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
label={t("post.actions.delete")}
onClick={handleDelete}
/>
)}
{canEdit && (
<IconButton
icon={<FiMoreHorizontal size={14} />}
label={t("post.actions.more")}
/>
<div ref={menuRef} className="relative">
<IconButton
icon={<FiMoreHorizontal size={14} />}
label={t("post.actions.more")}
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); }}
/>
{menuOpen && (
<div className="absolute right-0 top-full z-20 mt-1 min-w-[140px] overflow-hidden rounded-xl border border-brand-lines/20 bg-brand-bg shadow-lg">
{canDelete && (
<button
type="button"
onClick={handleDelete}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-brand-lines/10 transition-colors"
>
<FiTrash2 size={14} />
{t("post.actions.delete")}
</button>
)}
</div>
)}
</div>
)}
@@ -167,6 +190,8 @@ export default function Post({
replyCount={post.reply_count}
voteScore={post.vote_score}
initialUserVote={post.user_vote}
initialIsSaved={post.is_saved}
initialSaveCount={post.save_count}
onReplyClick={onReplyClick}
/>
)}

View File

@@ -1,8 +1,9 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiMessageSquare, FiArrowUp, FiArrowDown, FiShare2 } from "react-icons/fi";
import { FiMessageSquare, FiArrowUp, FiArrowDown, FiShare2, FiBookmark } from "react-icons/fi";
import IconButton from "@/components/ui/IconButton";
import { apiSocialPostsVoteCreate } from "@/api/generated/private/posts/posts";
import { privateApi } from "@/api/privateClient";
import SharePopup from "./SharePopup";
interface Props {
@@ -10,6 +11,8 @@ interface Props {
replyCount?: number;
voteScore?: number;
initialUserVote?: -1 | 0 | 1;
initialIsSaved?: boolean;
initialSaveCount?: number;
onReplyClick?: () => void;
}
@@ -18,6 +21,8 @@ export default function PostActions({
replyCount,
voteScore,
initialUserVote,
initialIsSaved,
initialSaveCount,
onReplyClick,
}: Props) {
const { t } = useTranslation("social");
@@ -27,6 +32,8 @@ export default function PostActions({
const [showShare, setShowShare] = useState(false);
const [upHover, setUpHover] = useState(false);
const [downHover, setDownHover] = useState(false);
const [saved, setSaved] = useState(initialIsSaved ?? false);
const [saveCount, setSaveCount] = useState(initialSaveCount ?? 0);
async function castVote(value: 1 | -1) {
if (busy) return;
@@ -42,6 +49,18 @@ export default function PostActions({
}
}
async function toggleSave() {
const next = !saved;
setSaved(next);
setSaveCount((c) => c + (next ? 1 : -1));
try {
await privateApi.post(`/api/social/posts/${postId}/save/`);
} catch {
setSaved(!next);
setSaveCount((c) => c + (next ? -1 : 1));
}
}
const upActive = vote === 1;
const downActive = vote === -1;
@@ -52,7 +71,7 @@ export default function PostActions({
<button
type="button"
onClick={onReplyClick}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-sm hover:bg-brand-lines/10 hover:text-brand-accent transition-colors"
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-2 text-sm hover:bg-brand-lines/10 hover:text-brand-accent transition-colors"
aria-label={t("post.actions.reply")}
title={t("post.actions.reply")}
>
@@ -64,28 +83,31 @@ export default function PostActions({
{/* Vote pill */}
<div className="inline-flex items-center overflow-hidden rounded-full border border-brand-lines/20">
{/* Upvote — pseudo-element carries the gradient so opacity can transition */}
<button
type="button"
disabled={busy}
onClick={() => castVote(1)}
{/* Upvote */}
<div
role="button"
tabIndex={0}
aria-label={t("post.actions.upvote")}
aria-disabled={busy}
onClick={() => !busy && castVote(1)}
onKeyDown={(e) => e.key === "Enter" && !busy && castVote(1)}
onMouseEnter={() => setUpHover(true)}
onMouseLeave={() => setUpHover(false)}
aria-label={t("post.actions.upvote")}
title={t("post.actions.upvote")}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
className={[
"relative flex items-center justify-center px-2.5 py-1.5 disabled:opacity-50",
"relative flex cursor-pointer items-center justify-center px-2.5 py-2 select-none",
busy ? "opacity-50 pointer-events-none" : "",
"before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200",
upActive ? "before:opacity-100" : upHover ? "before:opacity-40" : "before:opacity-0",
].join(" ")}
>
<FiArrowUp
size={15}
size={16}
style={{ color: upActive ? "rgb(12 74 110)" : undefined }}
className="relative z-10 text-brand-text/50"
/>
</button>
</div>
<div className="w-px self-stretch bg-brand-lines/20" />
<span
className={[
@@ -96,30 +118,48 @@ export default function PostActions({
{score}
</span>
{/* Downvote — same pseudo-element trick */}
<button
type="button"
disabled={busy}
onClick={() => castVote(-1)}
<div className="w-px self-stretch bg-brand-lines/20" />
{/* Downvote */}
<div
role="button"
tabIndex={0}
aria-label={t("post.actions.downvote")}
aria-disabled={busy}
onClick={() => !busy && castVote(-1)}
onKeyDown={(e) => e.key === "Enter" && !busy && castVote(-1)}
onMouseEnter={() => setDownHover(true)}
onMouseLeave={() => setDownHover(false)}
aria-label={t("post.actions.downvote")}
title={t("post.actions.downvote")}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
className={[
"relative flex items-center justify-center rounded-r-full rounded-l-none border-none px-2.5 py-1.5 disabled:opacity-50",
"relative flex cursor-pointer items-center justify-center px-2.5 py-2 select-none",
busy ? "opacity-50 pointer-events-none" : "",
"before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200",
downActive ? "before:opacity-100" : downHover ? "before:opacity-40" : "before:opacity-0",
].join(" ")}
>
<FiArrowDown
size={15}
size={16}
style={{ color: downActive ? "rgb(12 74 110)" : undefined }}
className="relative z-10 text-brand-text/50"
/>
</button>
</div>
</div>
{/* Save */}
<button
type="button"
onClick={toggleSave}
className={[
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-2 text-sm transition-colors hover:bg-brand-lines/10",
saved ? "text-brand-accent" : "hover:text-brand-accent",
].join(" ")}
aria-label={t("post.actions.save")}
title={t("post.actions.save")}
>
<FiBookmark size={16} fill={saved ? "currentColor" : "none"} />
{saveCount > 0 && <span className="text-xs tabular-nums">{saveCount}</span>}
</button>
{/* Share */}
<IconButton
icon={<FiShare2 size={16} />}

View File

@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FiSend, FiImage, FiX } from "react-icons/fi";
import { FiSend, FiPaperclip, FiX, FiPlay, FiFile } from "react-icons/fi";
import { useQueryClient } from "@tanstack/react-query";
import Textarea from "@/components/ui/Textarea";
import Button from "@/components/ui/Button";
@@ -9,36 +9,32 @@ import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts";
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
import { privateApi } from "@/api/privateClient";
interface Props {
parentId?: number;
defaultHubId?: number | null;
hubId?: number | null;
onPosted?: () => void;
}
interface ComposerForm {
content: string;
hub: number | null;
}
export default function PostComposer({ parentId, defaultHubId, onPosted }: Props) {
export default function PostComposer({ parentId, hubId, onPosted }: Props) {
const { t } = useTranslation("social");
const queryClient = useQueryClient();
const { data: hubsData } = useApiSocialHubsList(undefined);
const [rootError, setRootError] = useState<string | undefined>();
const [files, setFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<ComposerForm>({
defaultValues: { content: "", hub: defaultHubId ?? null },
defaultValues: { content: "" },
});
const { register, handleSubmit, formState, reset, watch, clearErrors } = form;
const { errors, isSubmitting } = formState;
const hubs = hubsData?.results ?? [];
const content = watch("content");
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -61,7 +57,7 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
try {
const created = await apiSocialPostsCreate({
content: values.content,
hub: values.hub ?? null,
hub: hubId ?? null,
reply_to: parentId ?? null,
} as Parameters<typeof apiSocialPostsCreate>[0]);
@@ -75,7 +71,7 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
previews.forEach((url) => URL.revokeObjectURL(url));
setFiles([]);
setPreviews([]);
reset({ content: "", hub: defaultHubId ?? null });
reset({ content: "" });
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
onPosted?.();
} catch (err) {
@@ -111,49 +107,58 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
})}
/>
{/* Image previews */}
{/* File previews */}
{previews.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{previews.map((src, i) => (
<div key={src} className="relative">
<img
src={src}
alt=""
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20"
/>
<button
type="button"
onClick={() => removeFile(i)}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
aria-label={t("post.compose.removeImage")}
>
<FiX size={11} />
</button>
</div>
))}
{previews.map((src, i) => {
const file = files[i];
const isVideo = file?.type.startsWith("video/");
const isImage = file?.type.startsWith("image/");
return (
<div key={src} className="relative">
{isVideo ? (
<div className="relative h-20 w-20">
<video
src={src}
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20 bg-black"
muted
playsInline
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/30 pointer-events-none">
<FiPlay size={22} className="text-white drop-shadow" fill="white" />
</div>
</div>
) : isImage ? (
<img
src={src}
alt=""
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20"
/>
) : (
<div className="flex h-20 w-32 flex-col items-center justify-center gap-1 rounded-xl border border-brand-lines/20 bg-brand-bgLight/40 px-2">
<FiFile size={22} className="text-brand-text/50 shrink-0" />
<span className="w-full truncate text-center text-[10px] text-brand-text/60 leading-tight">
{file?.name}
</span>
</div>
)}
<button
type="button"
onClick={() => removeFile(i)}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
aria-label={t("post.compose.removeImage")}
>
<FiX size={11} />
</button>
</div>
);
})}
</div>
)}
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
{/* Hub selector — top-level posts only */}
{!parentId && (
<select
disabled={isSubmitting}
className="rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-2 py-1.5 text-sm text-brand-text focus:outline-none focus:border-brand-accent"
{...register("hub", {
setValueAs: (v) => (v === "" || v == null ? null : Number(v)),
})}
>
<option value="">{t("post.compose.noHub")}</option>
{hubs.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
)}
{/* Image attach button */}
<button
type="button"
@@ -163,12 +168,12 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
aria-label={t("post.compose.attachImage")}
title={t("post.compose.attachImage")}
>
<FiImage size={16} />
<FiPaperclip size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
accept="*/*"
multiple
className="hidden"
onChange={handleFileChange}

View File

@@ -1,3 +1,5 @@
import { mediaUrl } from "@/utils/mediaUrl";
interface Props {
name?: string | null;
src?: string | null;
@@ -14,10 +16,11 @@ function initialsOf(name?: string | null): string {
export default function Avatar({ name, src, size = 40, className = "" }: Props) {
const dim = { width: size, height: size };
if (src) {
const resolvedSrc = mediaUrl(src);
if (resolvedSrc) {
return (
<img
src={src}
src={resolvedSrc}
alt={name ?? ""}
style={dim}
className={`rounded-full object-cover border border-brand-lines/20 ${className}`}

View File

@@ -1,15 +1,15 @@
import type { ReactNode } from "react";
import { createContext, useContext, useState, useEffect } from "react";
import { createContext, useContext, useState, useEffect, useRef } from "react";
// Import z Orval generovaného API
import { apiAccountLogoutCreate } from "@/api/generated/public/account";
import { apiAccountUserMeRetrieve } from "@/api/generated/private/account/account";
import { privateApi } from "@/api/privateClient";
// Import typů z Orval
import type { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair";
import type { CustomUser } from "@/api/generated/private/models/customUser";
export const AUTH_FLAG = "vontor_was_logged_in";
interface AuthContextType {
user: CustomUser | null;
isAuthenticated: boolean;
@@ -24,47 +24,67 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CustomUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const initialized = useRef(false);
const isAuthenticated = !!user;
// Načíst uživatele při načtení aplikace (pokud má cookies)
useEffect(() => {
refreshUser();
}, []);
async function refreshUser() {
const wasLoggedIn = localStorage.getItem(AUTH_FLAG);
if (!wasLoggedIn) {
setUser(null);
setIsLoading(false);
return;
}
try {
const userData = await apiAccountUserMeRetrieve();
setUser(userData);
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message;
console.error("Failed to refresh user:", errorMessage);
} catch {
setUser(null);
localStorage.removeItem(AUTH_FLAG);
} finally {
setIsLoading(false);
}
}
useEffect(() => {
if (!initialized.current) {
initialized.current = true;
refreshUser();
}
}, []);
async function login(payload: CustomTokenObtainPair) {
// Do NOT touch isLoading here — that flag is only for the initial auth
// bootstrap on mount. Toggling it during login causes PublicOnlyRoute to
// swap the Login page for a spinner, which unmounts the form and wipes
// its local error state before the user can see it.
// The Login page tracks its own submitting state via react-hook-form.
// Must use privateApi (withCredentials: true) so the browser stores the
// Set-Cookie headers from the login response. publicApi drops them silently.
await privateApi.post("/api/account/login/", payload);
await refreshUser();
setIsLoading(true);
try {
await privateApi.post("/api/account/login/", payload);
localStorage.setItem(AUTH_FLAG, "true");
await refreshUser();
} catch (err: any) {
setIsLoading(false);
const data = err.response?.data;
const errorMessage =
data?.detail ||
(typeof data === "object" && !Array.isArray(data)
? Object.values(data).flat().filter(Boolean).join(" ")
: null) ||
err.message ||
"Login failed";
throw new Error(errorMessage);
}
}
async function logout() {
setIsLoading(true);
try {
// Zavolej logout endpoint (smaže cookies na backendu)
localStorage.removeItem(AUTH_FLAG);
await apiAccountLogoutCreate();
} catch (err: any) {
console.error("Logout error:", err);
} finally {
setUser(null);
setIsLoading(false);
}
}
@@ -79,4 +99,4 @@ export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
}

View File

@@ -3,6 +3,7 @@
"feed": "Feed",
"chats": "Zprávy",
"hubs": "Huby",
"saved": "Uložené",
"profile": "Profil",
"logout": "Odhlásit"
},
@@ -37,7 +38,8 @@
"more": "Více možností",
"delete": "Smazat",
"edit": "Upravit",
"share": "Sdílet"
"share": "Sdílet",
"save": "Uložit"
},
"thread": {
"parents": "Vlákno výše",

View File

@@ -5,6 +5,7 @@ import {
FiMessageCircle,
FiUsers,
FiUser,
FiBookmark,
FiLogOut,
} from "react-icons/fi";
import { useAuth } from "@/context/AuthContext";
@@ -16,13 +17,14 @@ interface NavItem {
labelKey: string;
}
function buildItems(userId?: number): NavItem[] {
function buildItems(username?: string): NavItem[] {
return [
{ to: "/social/feed", icon: <FiHome size={22} />, labelKey: "nav.feed" },
{ to: "/social/chats", icon: <FiMessageCircle size={22} />, labelKey: "nav.chats" },
{ to: "/social/hubs", icon: <FiUsers size={22} />, labelKey: "nav.hubs" },
{ to: "/social/saved", icon: <FiBookmark size={22} />, labelKey: "nav.saved" },
{
to: userId ? `/social/profile/${userId}` : "/social/profile",
to: username ? `/social/profile/${username}` : "/social/feed",
icon: <FiUser size={22} />,
labelKey: "nav.profile",
},
@@ -32,7 +34,7 @@ function buildItems(userId?: number): NavItem[] {
export default function SocialLayout() {
const { t } = useTranslation("social");
const { user } = useAuth();
const items = buildItems(user?.id);
const items = buildItems(user?.username);
return (
<div className="min-h-screen w-full">
@@ -62,7 +64,7 @@ export default function SocialLayout() {
</div>
<div className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 hover:bg-brand-lines/10">
<Avatar name={user?.username ?? user?.email ?? "?"} size={36} />
<Avatar name={user?.username ?? user?.email ?? "?"} src={(user as any)?.avatar ?? null} size={36} />
<div className="hidden min-w-0 md:block">
<div className="truncate text-sm font-semibold text-brand-text">
{user?.username ?? "—"}

View File

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

View File

@@ -41,7 +41,7 @@ export default function ProfilePage() {
to="/social/logout"
className="inline-flex items-center gap-2 rounded-xl border border-brand-lines/20 px-3 py-2 text-sm text-brand-text/80 hover:bg-brand-lines/10 hover:text-brand-accent"
>
<FiLogOut size={14} /> {t("nav.logout")}
<FiLogOut size={16} /> {t("nav.logout")}
</Link>
</div>
</div>

View File

@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { FiBookmark } from "react-icons/fi";
import Post from "@/components/social/posts/Post";
import EmptyState from "@/components/ui/EmptyState";
import Spinner from "@/components/ui/Spinner";
import { useQuery } from "@tanstack/react-query";
import { privateApi } from "@/api/privateClient";
export default function SavedPage() {
const { t } = useTranslation("social");
const { data, isLoading } = useQuery({
queryKey: ["social", "posts", "saved"],
queryFn: () =>
privateApi.get("/api/social/posts/saved/").then((r) => r.data),
});
const posts = data?.results ?? [];
return (
<div>
<header className="sticky top-0 z-10 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
<h1 className="text-lg font-bold text-brand-text">{t("nav.saved")}</h1>
</header>
{isLoading && (
<div className="flex justify-center py-10">
<Spinner size={28} />
</div>
)}
{!isLoading && posts.length === 0 && (
<EmptyState icon={<FiBookmark />} message={t("saved.empty", { defaultValue: "Žádné uložené příspěvky." })} />
)}
<div>
{posts.map((p: any) => (
<Post key={p.id} post={p} />
))}
</div>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import { useRef } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar } from "react-icons/fi";
import { useApiAccountUsersRetrieve } from "@/api/generated/private/account/account";
import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar, FiMapPin } from "react-icons/fi";
import { useQuery } from "@tanstack/react-query";
import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
import { useAuth } from "@/context/AuthContext";
import { privateApi } from "@/api/privateClient";
import Avatar from "@/components/ui/Avatar";
import { mediaUrl } from "@/utils/mediaUrl";
import Post from "@/components/social/posts/Post";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
@@ -17,24 +18,35 @@ function formatJoined(dateVal: Date | string | undefined): string {
}
export default function UserProfilePage() {
const { id } = useParams<{ id: string }>();
const userId = Number(id);
const { username } = useParams<{ username: string }>();
const { user: me } = useAuth();
const { t } = useTranslation("social");
const navigate = useNavigate();
const isOwnProfile = me?.id === userId;
const { data: profile, isLoading: profileLoading } = useApiAccountUsersRetrieve(userId);
const { data: profile, isLoading: profileLoading } = useQuery({
queryKey: ["account", "users", username],
queryFn: () =>
privateApi
.get(`/api/account/users/`, { params: { username } })
.then((r) => r.data?.results?.[0] ?? null),
enabled: !!username,
});
const isOwnProfile = me?.username === username;
const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList(
{ author: userId },
{ author: profile?.id },
{ query: { enabled: !!profile?.id } },
);
const posts = postsData?.results ?? [];
const p = profile as any;
const displayName = [p?.first_name, p?.last_name].filter(Boolean).join(" ") || p?.username || "";
return (
<div>
{/* Header */}
<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">
{/* Sticky back-nav */}
<header className="sticky top-0 z-20 flex items-center gap-3 bg-brand-bg/70 px-4 py-3 backdrop-blur">
<button
type="button"
onClick={() => navigate(-1)}
@@ -49,54 +61,60 @@ export default function UserProfilePage() {
</header>
{profileLoading ? (
<div className="flex justify-center py-12">
<Spinner size={28} />
</div>
<div className="flex justify-center py-12"><Spinner size={28} /></div>
) : !profile ? (
<EmptyState icon={<FiUser />} title={t("profile.notFound")} />
) : (
<>
{/* Profile card */}
<div className="border-b border-brand-lines/10 px-4 py-5">
<div className="flex items-start gap-4">
<Avatar
name={[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username}
src={(profile as any).avatar ?? null}
size={72}
/>
<div className="min-w-0 flex-1">
<div className="text-xl font-bold text-brand-text">
{[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username}
</div>
<div className="text-sm text-brand-text/60">@{profile.username}</div>
{/* Banner + avatar */}
<div className="relative">
{/* Banner */}
<div className="h-36 w-full overflow-hidden bg-gradient-to-br from-brand-bgLight to-brand-lines/20">
{p?.banner && (
<img
src={mediaUrl(p.banner) ?? p.banner}
alt=""
className="h-full w-full object-cover"
/>
)}
</div>
{(profile as any).city && (
<div className="mt-1 text-sm text-brand-text/50">{(profile as any).city}</div>
)}
{(profile as any).create_time && (
<div className="mt-1 flex items-center gap-1 text-xs text-brand-text/40">
<FiCalendar size={12} />
{t("profile.joined", { date: formatJoined((profile as any).create_time) })}
</div>
)}
{/* Avatar — overlaps banner */}
<div className="absolute -bottom-10 left-4">
<div className="rounded-full ring-4 ring-brand-bg">
<Avatar name={displayName} src={p?.avatar ?? null} size={80} />
</div>
</div>
{isOwnProfile && (
<div className="flex flex-col items-end gap-2">
<Link
to="/social/account/settings"
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-lines/20 px-3 py-1.5 text-sm text-brand-text/70 hover:bg-brand-lines/10 hover:text-brand-text transition-colors"
>
<FiSettings size={13} /> {t("profile.editProfile")}
</Link>
<Link
to="/social/logout"
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-lines/20 px-3 py-1.5 text-sm text-brand-text/70 hover:bg-brand-lines/10 hover:text-red-400 transition-colors"
>
<FiLogOut size={13} /> {t("nav.logout")}
</Link>
</div>
{/* Action buttons — top-right of banner area */}
{isOwnProfile && (
<div className="absolute bottom-3 right-4 flex items-center gap-2">
<Link
to="/social/account/settings"
className="inline-flex items-center gap-1.5 rounded-full border border-brand-lines/30 bg-brand-bg/80 px-3 py-1.5 text-sm text-brand-text/80 backdrop-blur hover:bg-brand-lines/10 transition-colors"
>
<FiSettings size={16} /> {t("profile.editProfile")}
</Link>
</div>
)}
</div>
{/* Profile info */}
<div className="border-b border-brand-lines/10 px-4 pb-4 pt-12">
<div className="text-xl font-bold text-brand-text">{displayName}</div>
<div className="text-sm text-brand-text/50">@{profile.username}</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-brand-text/40">
{p?.city && (
<span className="flex items-center gap-1">
<FiMapPin size={12} /> {p.city}
</span>
)}
{p?.create_time && (
<span className="flex items-center gap-1">
<FiCalendar size={12} />
{t("profile.joined", { date: formatJoined(p.create_time) })}
</span>
)}
</div>
</div>
@@ -104,17 +122,15 @@ export default function UserProfilePage() {
{/* Posts */}
<div>
{postsLoading ? (
<div className="flex justify-center py-8">
<Spinner size={22} />
</div>
<div className="flex justify-center py-8"><Spinner size={22} /></div>
) : posts.length === 0 ? (
<EmptyState message={t("profile.noPosts")} />
) : (
posts.map((p) => (
posts.map((post) => (
<Post
key={p.id}
post={p}
onReplyClick={() => navigate(`/social/post/${p.id}`)}
key={post.id}
post={post}
onReplyClick={() => navigate(`/social/post/${post.id}`)}
/>
))
)}

View File

@@ -0,0 +1,19 @@
const BACKEND_ORIGIN = (() => {
try {
return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin;
} catch {
return "http://localhost:8000";
}
})();
export function mediaUrl(src: string | null | undefined): string | null {
if (!src) return null;
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
try {
const url = new URL(src);
if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search;
} catch {
// already a relative path — return as-is
}
return src;
}