added frontend for social + feed partiali working

This commit is contained in:
2026-05-18 02:25:47 +02:00
parent e1df55df0e
commit 202ce22102
88 changed files with 4236 additions and 737 deletions

10
frontend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
VITE_BACKEND_URL="http://localhost:8000/api/"
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL
# (the `/api` suffix is stripped automatically; only the host is used).
# VITE_WS_URL="ws://localhost:8000"
# Auth endpoints (defaults match Django routes; only override if you changed them).
# VITE_API_REFRESH_URL=/api/token/refresh/
# VITE_LOGIN_PATH=/social/login

View File

@@ -24,9 +24,11 @@
"axios": "^1.13.0",
"dayjs": "^1.11.19",
"framer-motion": "^12.25.0",
"i18next": "^26.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.70.0",
"react-i18next": "^17.0.8",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.1",
"react-toastify": "^11.0.5",
@@ -3589,9 +3591,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001734",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz",
"integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==",
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"dev": true,
"funding": [
{
@@ -4668,6 +4670,15 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
@@ -4678,6 +4689,34 @@
"node": ">=18.18.0"
}
},
"node_modules/i18next": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5900,6 +5939,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -6589,7 +6655,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -6901,6 +6967,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -27,9 +27,11 @@
"axios": "^1.13.0",
"dayjs": "^1.11.19",
"framer-motion": "^12.25.0",
"i18next": "^26.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.70.0",
"react-i18next": "^17.0.8",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.1",
"react-toastify": "^11.0.5",

View File

@@ -1,13 +1,14 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
import HomeLayout from "./layouts/HomeLayout";
import SocialLayout from "./layouts/social/SocialLayout";
import ChatLayout from "./layouts/social/Chat";
import Downloader from "./pages/downloader/Downloader";
import Home from "./pages/home/home";
import DroneServisSection from "./pages/home/components/Services/droneServis";
import PrivateRoute from "./routes/PrivateRoute";
import PublicOnlyRoute from "./routes/PublicOnlyRoute";
// Pages
import PortfolioPage from "./pages/portfolio/PortfolioPage";
@@ -17,44 +18,65 @@ import ScrollToTop from "./components/common/ScrollToTop";
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 { RetroSoundTest } from "./pages/test/sounds";
// Social pages
import FeedPage from "./pages/social/FeedPage";
import PostPage from "./pages/social/PostPage";
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 ChatsIndexPage from "./pages/social/chat/ChatsPage";
import ChatRoomPage from "./pages/social/chat/ChatRoomPage";
export default function App() {
return (
<Router>
<ScrollToTop />
<Routes>
{/* Public routes */}
{/* Public marketing routes */}
<Route path="/" element={<HomeLayout />}>
<Route index element={<Home />} />
<Route path="portfolio" element={<PortfolioPage />} />
<Route path="contact" element={<ContactPage />} />
{/* APPS */}
<Route path="apps/downloader" element={<Downloader />} />
{/* SERVICES */}
<Route path="services/drone" element={< DroneServisSection />} />
<Route path="services/drone" element={<DroneServisSection />} />
<Route path="services/web" element={<Downloader />} />
<Route path="test/sounds" element={<RetroSoundTest />} />
</Route>
<Route path="auth/" element={<PrivateRoute />}>
{/* Public-only social auth */}
<Route path="/social" element={<PublicOnlyRoute />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
<Route path="logout" element={<LogoutPage />} />
</Route>
<Route path="password-reset" element={<PasswordResetPage />} />
</Route>
{/* Example protected route group (kept for future use) */}
<Route element={<PrivateRoute />}>
<Route path="/" element={<ChatLayout />}>
{/* Authenticated social area */}
<Route path="/social" element={<PrivateRoute />}>
<Route element={<SocialLayout />}>
<Route index element={<Navigate to="/social/feed" replace />} />
<Route path="feed" element={<FeedPage />} />
<Route path="post/:id" element={<PostPage />} />
<Route path="hubs" element={<HubsPage />} />
<Route path="hub/:id" element={<HubPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="profile/:id" element={<UserProfilePage />} />
<Route path="chats" element={<ChatLayout />}>
<Route index element={<ChatsIndexPage />} />
<Route path=":chatId" element={<ChatRoomPage />} />
</Route>
</Route>
</Routes>
<Route path="logout" element={<LogoutPage />} />
</Route>
{/* Legacy /auth redirects */}
<Route path="/auth/login" element={<Navigate to="/social/login" replace />} />
<Route path="/auth/register" element={<Navigate to="/social/register" replace />} />
<Route path="/auth/logout" element={<Navigate to="/social/logout" replace />} />
</Routes>
</Router>
);
}
}

View File

@@ -5,14 +5,18 @@
*/
export type ApiSocialMessagesListParams = {
/**
* The pagination cursor value.
*/
cursor?: string;
/**
* Which field to use when ordering the results.
*/
ordering?: string;
/**
* A page number within the paginated result set.
* Number of results to return per page.
*/
page?: number;
page_size?: number;
/**
* A search term.
*/

View File

@@ -0,0 +1,31 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export type ApiSocialPostsFeedListParams = {
author?: number;
/**
* Opaque pagination cursor.
*/
cursor?: string;
/**
* Algorithm key, default `recent`.
*/
feed_strategy?: string;
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

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

View File

@@ -0,0 +1,20 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export interface AuthorMinimal {
readonly id: number;
/**
* Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_.
* @maxLength 150
* @pattern ^[\w.@+-]+$
*/
username: string;
/** @maxLength 150 */
first_name?: string;
/** @maxLength 150 */
last_name?: string;
readonly avatar: string;
}

View File

@@ -42,4 +42,6 @@ export interface CustomUser {
postal_code?: string | null;
readonly gdpr: boolean;
is_active?: boolean;
/** @nullable */
avatar?: string | null;
}

View File

@@ -26,8 +26,11 @@ export * from "./apiSocialHubsModeratorsListParams";
export * from "./apiSocialHubsTagsListParams";
export * from "./apiSocialChatsListParams";
export * from "./apiSocialMessagesListParams";
export * from "./apiSocialPostsFeedListParams";
export * from "./apiSocialPostsListParams";
export * from "./apiSocialPostsMediaCreateBody";
export * from "./apiZasilkovnaShipmentsListParams";
export * from "./authorMinimal";
export * from "./callback";
export * from "./carrierRead";
export * from "./cart";

View File

@@ -6,7 +6,6 @@
import type { Message } from "./message";
export interface PaginatedMessageList {
count: number;
/** @nullable */
next?: string | null;
/** @nullable */

View File

@@ -42,4 +42,6 @@ export interface PatchedCustomUser {
postal_code?: string | null;
readonly gdpr?: boolean;
is_active?: boolean;
/** @nullable */
avatar?: string | null;
}

View File

@@ -3,6 +3,7 @@
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
import type { Tags } from "./tags";
@@ -12,10 +13,14 @@ export interface PatchedPost {
readonly created_at?: Date;
readonly updated_at?: Date;
readonly author?: number;
readonly author_detail?: AuthorMinimal;
/** @nullable */
hub?: number | null;
/** @nullable */
reply_to?: number | null;
readonly tags?: readonly Tags[];
readonly contents?: readonly PostContent[];
readonly vote_score?: string;
readonly user_vote?: string;
readonly reply_count?: number;
}

View File

@@ -3,6 +3,7 @@
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
import type { Tags } from "./tags";
@@ -12,10 +13,14 @@ export interface Post {
readonly created_at: Date;
readonly updated_at: Date;
readonly author: number;
readonly author_detail: AuthorMinimal;
/** @nullable */
hub?: number | null;
/** @nullable */
reply_to?: number | null;
readonly tags: readonly Tags[];
readonly contents: readonly PostContent[];
readonly vote_score: string;
readonly user_vote: string;
readonly reply_count: number;
}

View File

@@ -5,16 +5,22 @@
*/
export interface UserRegistration {
/**
* Užívatelské jméno
* @maxLength 150
* @pattern ^[\w.@+-]+$
*/
username?: string;
/**
* Křestní jméno uživatele
* @maxLength 150
*/
first_name: string;
first_name?: string;
/**
* Příjmení uživatele
* @maxLength 150
*/
last_name: string;
last_name?: string;
/**
* Emailová adresa uživatele
* @maxLength 254
@@ -26,7 +32,7 @@ export interface UserRegistration {
* @nullable
* @pattern ^\+?\d{9,15}$
*/
phone_number: string | null;
phone_number?: string | null;
/** Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici. */
password: string;
/**
@@ -34,20 +40,20 @@ export interface UserRegistration {
* @maxLength 100
* @nullable
*/
city: string | null;
city?: string | null;
/**
* Ulice uživatele
* @maxLength 200
* @nullable
*/
street: string | null;
street?: string | null;
/**
* PSČ uživatele
* @maxLength 5
* @nullable
* @pattern ^\d{5}$
*/
postal_code: string | null;
postal_code?: string | null;
/** Souhlas se zpracováním osobních údajů */
gdpr: boolean;
}

View File

@@ -20,10 +20,13 @@ import type {
} from "@tanstack/react-query";
import type {
ApiSocialPostsFeedListParams,
ApiSocialPostsListParams,
ApiSocialPostsMediaCreateBody,
PaginatedPostList,
PatchedPost,
Post,
PostContent,
PostVote,
TagAttach,
} from "../models";
@@ -712,6 +715,97 @@ export const useApiSocialPostsDestroy = <TError = unknown, TContext = unknown>(
queryClient,
);
};
/**
* Attach an image or video file to a post. Only the post author can upload.
* @summary Upload media to a post
*/
export const apiSocialPostsMediaCreate = (
id: number,
apiSocialPostsMediaCreateBody: ApiSocialPostsMediaCreateBody,
signal?: AbortSignal,
) => {
const formData = new FormData();
formData.append(`file`, apiSocialPostsMediaCreateBody.file);
return privateMutator<PostContent>({
url: `/api/social/posts/${id}/media/`,
method: "POST",
data: formData,
signal,
});
};
export const getApiSocialPostsMediaCreateMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsMediaCreate>>,
TError,
{ id: number; data: ApiSocialPostsMediaCreateBody },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsMediaCreate>>,
TError,
{ id: number; data: ApiSocialPostsMediaCreateBody },
TContext
> => {
const mutationKey = ["apiSocialPostsMediaCreate"];
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 apiSocialPostsMediaCreate>>,
{ id: number; data: ApiSocialPostsMediaCreateBody }
> = (props) => {
const { id, data } = props ?? {};
return apiSocialPostsMediaCreate(id, data);
};
return { mutationFn, ...mutationOptions };
};
export type ApiSocialPostsMediaCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiSocialPostsMediaCreate>>
>;
export type ApiSocialPostsMediaCreateMutationBody =
ApiSocialPostsMediaCreateBody;
export type ApiSocialPostsMediaCreateMutationError = unknown;
/**
* @summary Upload media to a post
*/
export const useApiSocialPostsMediaCreate = <
TError = unknown,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsMediaCreate>>,
TError,
{ id: number; data: ApiSocialPostsMediaCreateBody },
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiSocialPostsMediaCreate>>,
TError,
{ id: number; data: ApiSocialPostsMediaCreateBody },
TContext
> => {
return useMutation(
getApiSocialPostsMediaCreateMutationOptions(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
@@ -976,3 +1070,162 @@ export const useApiSocialPostsVoteCreate = <
queryClient,
);
};
/**
* Returns a cursor-paginated stream of top-level posts (excluding replies) aggregated from the user's joined hubs, public hubs, and hub-less posts. Pass `feed_strategy` to switch between ranking algorithms (currently only `recent` is implemented; reserved for future custom algorithms).
* @summary Get the user's post feed
*/
export const apiSocialPostsFeedList = (
params?: ApiSocialPostsFeedListParams,
signal?: AbortSignal,
) => {
return privateMutator<PaginatedPostList>({
url: `/api/social/posts/feed/`,
method: "GET",
params,
signal,
});
};
export const getApiSocialPostsFeedListQueryKey = (
params?: ApiSocialPostsFeedListParams,
) => {
return [`/api/social/posts/feed/`, ...(params ? [params] : [])] as const;
};
export const getApiSocialPostsFeedListQueryOptions = <
TData = Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError = unknown,
>(
params?: ApiSocialPostsFeedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
TData
>
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getApiSocialPostsFeedListQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>
> = ({ signal }) => apiSocialPostsFeedList(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ApiSocialPostsFeedListQueryResult = NonNullable<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>
>;
export type ApiSocialPostsFeedListQueryError = unknown;
export function useApiSocialPostsFeedList<
TData = Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError = unknown,
>(
params: undefined | ApiSocialPostsFeedListParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
Awaited<ReturnType<typeof apiSocialPostsFeedList>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiSocialPostsFeedList<
TData = Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError = unknown,
>(
params?: ApiSocialPostsFeedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
Awaited<ReturnType<typeof apiSocialPostsFeedList>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiSocialPostsFeedList<
TData = Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError = unknown,
>(
params?: ApiSocialPostsFeedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get the user's post feed
*/
export function useApiSocialPostsFeedList<
TData = Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError = unknown,
>(
params?: ApiSocialPostsFeedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsFeedList>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getApiSocialPostsFeedListQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -0,0 +1,20 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export interface AuthorMinimal {
readonly id: number;
/**
* Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_.
* @maxLength 150
* @pattern ^[\w.@+-]+$
*/
username: string;
/** @maxLength 150 */
first_name?: string;
/** @maxLength 150 */
last_name?: string;
readonly avatar: string;
}

View File

@@ -42,4 +42,6 @@ export interface CustomUser {
postal_code?: string | null;
readonly gdpr: boolean;
is_active?: boolean;
/** @nullable */
avatar?: string | null;
}

View File

@@ -17,6 +17,7 @@ export * from "./apiDownloaderDownloadRetrieveParams";
export * from "./apiChoicesRetrieve200";
export * from "./apiChoicesRetrieve200Item";
export * from "./apiChoicesRetrieveParams";
export * from "./authorMinimal";
export * from "./callback";
export * from "./carrierRead";
export * from "./cart";

View File

@@ -6,7 +6,6 @@
import type { Message } from "./message";
export interface PaginatedMessageList {
count: number;
/** @nullable */
next?: string | null;
/** @nullable */

View File

@@ -42,4 +42,6 @@ export interface PatchedCustomUser {
postal_code?: string | null;
readonly gdpr?: boolean;
is_active?: boolean;
/** @nullable */
avatar?: string | null;
}

View File

@@ -3,6 +3,7 @@
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
import type { Tags } from "./tags";
@@ -12,10 +13,14 @@ export interface PatchedPost {
readonly created_at?: Date;
readonly updated_at?: Date;
readonly author?: number;
readonly author_detail?: AuthorMinimal;
/** @nullable */
hub?: number | null;
/** @nullable */
reply_to?: number | null;
readonly tags?: readonly Tags[];
readonly contents?: readonly PostContent[];
readonly vote_score?: string;
readonly user_vote?: string;
readonly reply_count?: number;
}

View File

@@ -3,6 +3,7 @@
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
import type { Tags } from "./tags";
@@ -12,10 +13,14 @@ export interface Post {
readonly created_at: Date;
readonly updated_at: Date;
readonly author: number;
readonly author_detail: AuthorMinimal;
/** @nullable */
hub?: number | null;
/** @nullable */
reply_to?: number | null;
readonly tags: readonly Tags[];
readonly contents: readonly PostContent[];
readonly vote_score: string;
readonly user_vote: string;
readonly reply_count: number;
}

View File

@@ -5,16 +5,22 @@
*/
export interface UserRegistration {
/**
* Užívatelské jméno
* @maxLength 150
* @pattern ^[\w.@+-]+$
*/
username?: string;
/**
* Křestní jméno uživatele
* @maxLength 150
*/
first_name: string;
first_name?: string;
/**
* Příjmení uživatele
* @maxLength 150
*/
last_name: string;
last_name?: string;
/**
* Emailová adresa uživatele
* @maxLength 254
@@ -26,7 +32,7 @@ export interface UserRegistration {
* @nullable
* @pattern ^\+?\d{9,15}$
*/
phone_number: string | null;
phone_number?: string | null;
/** Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici. */
password: string;
/**
@@ -34,20 +40,20 @@ export interface UserRegistration {
* @maxLength 100
* @nullable
*/
city: string | null;
city?: string | null;
/**
* Ulice uživatele
* @maxLength 200
* @nullable
*/
street: string | null;
street?: string | null;
/**
* PSČ uživatele
* @maxLength 5
* @nullable
* @pattern ^\d{5}$
*/
postal_code: string | null;
postal_code?: string | null;
/** Souhlas se zpracováním osobních údajů */
gdpr: boolean;
}

View File

@@ -0,0 +1,75 @@
/**
* Hand-written wrappers for endpoints not yet picked up by orval regen.
* Run `npm run api:gen` after running the backend to migrate to the generated client.
*/
import { privateMutator } from "../privateClient";
import type { Post } from "../generated/private/models/post";
import type { Message } from "../generated/private/models/message";
export interface CursorPaginated<T> {
next: string | null;
previous: string | null;
results: T[];
}
export type FeedStrategy = "recent";
export interface FeedParams {
cursor?: string | null;
feed_strategy?: FeedStrategy;
page_size?: number;
}
export const apiSocialPostsFeed = (
params?: FeedParams,
signal?: AbortSignal,
) =>
privateMutator<CursorPaginated<Post>>({
url: `/api/social/posts/feed/`,
method: "GET",
params,
signal,
});
export const feedQueryKey = (params?: Omit<FeedParams, "cursor">) =>
["social", "posts", "feed", params ?? {}] as const;
export interface RepliesParams {
reply_to: number;
cursor?: string | null;
page_size?: number;
}
export const apiSocialPostReplies = (
params: RepliesParams,
signal?: AbortSignal,
) =>
privateMutator<CursorPaginated<Post>>({
url: `/api/social/posts/`,
method: "GET",
params,
signal,
});
export const repliesQueryKey = (postId: number) =>
["social", "posts", "replies", postId] as const;
export interface MessagesParams {
chat: number;
cursor?: string | null;
page_size?: number;
}
export const apiSocialMessagesCursor = (
params: MessagesParams,
signal?: AbortSignal,
) =>
privateMutator<CursorPaginated<Message>>({
url: `/api/social/messages/`,
method: "GET",
params,
signal,
});
export const messagesQueryKey = (chatId: number) =>
["social", "messages", chatId] as const;

View File

@@ -0,0 +1,23 @@
/**
* Derives the WebSocket base URL from env. Mirrors the BE Channels routing,
* which lives behind the same host as the REST API.
*
* Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the
* scheme of `VITE_BACKEND_URL`.
*/
export function getChatSocketUrl(chatId: number | string): string {
const explicit = import.meta.env.VITE_WS_URL as string | undefined;
if (explicit) {
return `${stripTrailing(explicit)}/ws/chat/${chatId}/`;
}
const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined)
?? "http://localhost:8000";
const wsBase = backend.replace(/^http/, "ws");
// WS endpoints live at the host root (/ws/chat/<id>/), not under /api/.
const hostOnly = wsBase.replace(/\/api\/?$/, "");
return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`;
}
function stripTrailing(s: string): string {
return s.endsWith("/") ? s.slice(0, -1) : s;
}

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import {
FaUserCircle,
FaSignOutAlt,
FaSignInAlt,
FaBars,
@@ -14,15 +13,16 @@ import {
FaUsers,
FaHandsHelping,
} from "react-icons/fa";
import {FaClapperboard, FaCubes} from "react-icons/fa6";
import { FaClapperboard, FaCubes } from "react-icons/fa6";
import { useAuth } from "@/context/AuthContext";
import Avatar from "@/components/ui/Avatar";
import styles from "./navbar.module.css";
export default function Navbar() {
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
const handleLogin = () => navigate("/login");
const handleLogin = () => navigate("/social/login");
const handleLogout = async () => {
await logout();
navigate("/");
@@ -54,7 +54,11 @@ export default function Navbar() {
}, []);
return (
<nav className={`${styles.navbar} ${mobileMenu ? styles.mobileNavOpen : ""}`} ref={navRef} aria-label="Hlavní navigace">
<nav
className={`${styles.navbar} ${mobileMenu ? styles.mobileNavOpen : ""}`}
ref={navRef}
aria-label="Hlavní navigace"
>
{/* mobile burger */}
<button
className={styles.burger}
@@ -64,89 +68,101 @@ export default function Navbar() {
>
<FaBars />
</button>
{/* left: brand */}
<div className={styles.logo}>
<a href="/" aria-label="vontor.cz home">vontor.cz</a>
<Link to="/" aria-label="vontor.cz home">vontor.cz</Link>
</div>
{/* center links */}
<div className={`${styles.links} ${mobileMenu ? styles.show : ""}`} role="menubar">
{/* Services with submenu */}
<div className={styles.dropdownItem}>
<button
className={styles.linkButton}
aria-haspopup="true"
>
<FaHandsHelping className={styles.iconSmall}/> Služby <FaChevronDown className={styles.chev} />
<button className={styles.linkButton} aria-haspopup="true">
<FaHandsHelping className={styles.iconSmall} /> Služby{" "}
<FaChevronDown className={styles.chev} />
</button>
<div className={styles.dropdown} role="menu" aria-label="Služby submenu">
<a href="/services/web" role="menuitem"><FaGlobe className={styles.iconSmall}/> Weby</a>
{/* Filmařina as a simple link (no dropdown) */}
<a href="/services/film" role="menuitem">
<FaClapperboard className={styles.iconSmall}/> Filmařina
</a>
<a href="/services/drone-service" role="menuitem"><FaWrench className={styles.iconSmall}/> Servis dronu</a>
<Link to="/services/web" role="menuitem">
<FaGlobe className={styles.iconSmall} /> Weby
</Link>
<Link to="/services/film" role="menuitem">
<FaClapperboard className={styles.iconSmall} /> Filmařina
</Link>
<Link to="/services/drone" role="menuitem">
<FaWrench className={styles.iconSmall} /> Servis dronu
</Link>
</div>
</div>
{/* Aplikace standalone submenu */}
<div className={styles.dropdownItem}>
<button className={styles.linkButton} aria-haspopup="true">
<FaCubes className={styles.iconSmall}/> Aplikace <FaChevronDown className={styles.chev} />
<FaCubes className={styles.iconSmall} /> Aplikace{" "}
<FaChevronDown className={styles.chev} />
</button>
<div className={styles.dropdown} role="menu" aria-label="Aplikace submenu">
<a href="/apps/downloader" role="menuitem"><FaDownload className={styles.iconSmall}/> Downloader</a>
<a href="/apps/git" role="menuitem"><FaGitAlt className={styles.iconSmall}/> Git</a>
<a href="/apps/dema" role="menuitem"><FaPlayCircle className={styles.iconSmall}/> Dema</a>
<a href="/apps/social" role="menuitem"><FaUsers className={styles.iconSmall}/> Social</a>
<Link to="/apps/downloader" role="menuitem">
<FaDownload className={styles.iconSmall} /> Downloader
</Link>
<Link to="/apps/git" role="menuitem">
<FaGitAlt className={styles.iconSmall} /> Git
</Link>
<Link to="/apps/dema" role="menuitem">
<FaPlayCircle className={styles.iconSmall} /> Dema
</Link>
</div>
</div>
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
{/* Social entry — top-level link to the social area */}
<Link className={styles.linkSimple} to="/social/feed">
<FaUsers className={styles.iconSmall} /> Social
</Link>
<Link className={styles.linkSimple} to="/contact">
<FaGlobe className={styles.iconSmall} /> Kontakt
</Link>
{/* right: user area */}
{!isAuthenticated ? (
<a className={styles.linkSimple} onClick={handleLogin} aria-label="Přihlásit">
{!isAuthenticated || !user ? (
<button
type="button"
className={styles.linkSimple}
onClick={handleLogin}
aria-label="Přihlásit"
>
<FaSignInAlt className={styles.iconSmall} />
</a>
</button>
) : (
<div className={styles.dropdownItem}>
<button
className={styles.linkButton}
aria-haspopup="true"
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={`${user.username} avatar`} className={styles.avatar} />
) : (
<FaUserCircle className={styles.userIcon} />
)}
<button className={styles.linkButton} aria-haspopup="true">
<Avatar
name={user.username || user.email}
size={24}
className={styles.avatar}
/>
<span className={styles.username}>{user.username}</span>
<FaChevronDown className={styles.chev}/>
<FaChevronDown className={styles.chev} />
</button>
<div className={styles.dropdown} role="menu" aria-label="Uživatelské menu">
<a href="/me/profile" role="menuitem">Profil</a>
<a href="/me/settings" role="menuitem">Nastavení</a>
<a href="/me/billing" role="menuitem">Platby</a>
<Link to="/social/profile" role="menuitem">Profil</Link>
<Link to="/social/feed" role="menuitem">Feed</Link>
<Link to="/social/chats" role="menuitem">Zprávy</Link>
<button className={styles.logoutBtn} onClick={handleLogout} role="menuitem">
<button
type="button"
className={styles.logoutBtn}
onClick={handleLogout}
role="menuitem"
>
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se
</button>
</div>
</div>
)}
</div>
{/*FIXME: STRČIT USER ČÁST DO LINK SKUPINY ABY TO BYLO KOMPATIBILNI PRO MOBIL*/}
</nav>
);
}

View File

@@ -360,6 +360,8 @@
}
.navbar .logo{
margin: auto;
text-align: center;
border: none;
}

View File

@@ -0,0 +1,86 @@
import { useState, useMemo } from "react";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiSearch, FiPlus } from "react-icons/fi";
import { useApiSocialChatsList } from "@/api/generated/private/chat/chat";
import Avatar from "@/components/ui/Avatar";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
import IconButton from "@/components/ui/IconButton";
export default function ChatSidebar() {
const { t } = useTranslation("social");
const [query, setQuery] = useState("");
const { data, isLoading } = useApiSocialChatsList(
query ? { search: query } : undefined,
);
const chats = useMemo(() => data?.results ?? [], [data]);
return (
<aside className="flex h-full flex-col border-r border-brand-lines/15">
<header className="flex items-center justify-between gap-2 border-b border-brand-lines/10 px-3 py-3">
<h2 className="text-sm font-semibold text-brand-text">
{t("chat.sidebar.title")}
</h2>
<IconButton icon={<FiPlus size={16} />} label={t("chat.sidebar.new")} />
</header>
<div className="relative px-3 py-2">
<FiSearch
className="absolute left-5 top-1/2 -translate-y-1/2 text-brand-text/50"
size={14}
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("chat.sidebar.search")}
className="w-full rounded-xl bg-brand-bgLight/40 border border-brand-lines/20 py-2 pl-8 pr-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none focus:border-brand-accent"
/>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading && (
<div className="flex justify-center py-6">
<Spinner size={20} />
</div>
)}
{!isLoading && chats.length === 0 && (
<EmptyState message={t("chat.sidebar.empty")} />
)}
<ul>
{chats.map((chat) => (
<li key={chat.id}>
<NavLink
to={`/social/chats/${chat.id}`}
className={({ isActive }) =>
[
"flex items-center gap-3 px-3 py-2.5 hover:bg-brand-lines/10 transition-colors",
isActive ? "bg-brand-lines/10" : "",
].join(" ")
}
>
<Avatar
name={chat.name ?? `chat ${chat.id}`}
src={chat.icon ?? undefined}
size={36}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-brand-text">
{chat.name || `Chat #${chat.id}`}
</div>
<div className="truncate text-xs text-brand-text/60">
{chat.chat_type}
</div>
</div>
</NavLink>
</li>
))}
</ul>
</div>
</aside>
);
}

View File

@@ -0,0 +1,87 @@
import { useTranslation } from "react-i18next";
import { FiTrash2, FiCornerUpLeft, FiSmile } from "react-icons/fi";
import type { Message as MessageModel } from "@/api/generated/private/models/message";
import type { Chat } from "@/api/generated/private/models/chat";
import Avatar from "@/components/ui/Avatar";
import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/context/AuthContext";
import { canDeleteMessage } from "@/hooks/usePermissions";
import { formatRelative } from "@/utils/relativeTime";
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
interface Props {
message: MessageModel;
chat: Chat | null;
onReply?: (message: MessageModel) => void;
onReact?: (message: MessageModel, emoji: string) => void;
}
export default function Message({ message, chat, onReply, onReact }: Props) {
const { t } = useTranslation("social");
const { user } = useAuth();
const isOwn = user?.id != null && message.sender === user.id;
async function handleDelete() {
if (!confirm("Smazat zprávu?")) return;
await apiSocialMessagesDestroy(String(message.id));
// WS delete event will remove from the list; refresh cache as fallback.
}
const canDelete = canDeleteMessage(user, message, chat);
return (
<div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}>
<Avatar name={`user ${message.sender}`} size={28} />
<div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}>
<div
className={[
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap",
isOwn
? "bg-brand-accent text-brand-bg rounded-br-sm"
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",
].join(" ")}
>
{message.content}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-brand-text/50">
<time dateTime={String(message.created_at)}>
{formatRelative(message.created_at)}
</time>
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
</div>
{message.reactions?.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{message.reactions.map((r) => (
<span
key={r.id}
className="rounded-full bg-brand-bgLight/60 px-2 py-0.5 text-xs"
>
{r.emoji}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<IconButton
icon={<FiCornerUpLeft size={14} />}
label={t("chat.actions.reply")}
onClick={() => onReply?.(message)}
/>
<IconButton
icon={<FiSmile size={14} />}
label={t("chat.actions.react")}
onClick={() => onReact?.(message, "👍")}
/>
{canDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
label={t("chat.actions.delete")}
onClick={handleDelete}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiSend, FiX } from "react-icons/fi";
import type { Message } from "@/api/generated/private/models/message";
import Button from "@/components/ui/Button";
interface Props {
disabled?: boolean;
replyTo?: Message | null;
onCancelReply?: () => void;
onSend: (text: string, replyToId?: number) => boolean;
onTyping?: (isTyping: boolean) => void;
}
export default function MessageComposer({
disabled,
replyTo,
onCancelReply,
onSend,
onTyping,
}: Props) {
const { t } = useTranslation("social");
const [text, setText] = useState("");
const typingTimerRef = useRef<number | null>(null);
const isTypingRef = useRef(false);
useEffect(() => {
return () => {
if (typingTimerRef.current) window.clearTimeout(typingTimerRef.current);
if (isTypingRef.current) onTyping?.(false);
};
}, [onTyping]);
function notifyTyping() {
if (!isTypingRef.current) {
isTypingRef.current = true;
onTyping?.(true);
}
if (typingTimerRef.current) window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = window.setTimeout(() => {
isTypingRef.current = false;
onTyping?.(false);
}, 2500);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
const ok = onSend(trimmed, replyTo?.id);
if (ok) {
setText("");
if (isTypingRef.current) {
isTypingRef.current = false;
onTyping?.(false);
}
onCancelReply?.();
}
}
return (
<form
onSubmit={handleSubmit}
className="border-t border-brand-lines/15 bg-brand-bg/60 px-4 py-3"
>
{replyTo && (
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-brand-lines/15 bg-brand-bgLight/40 px-3 py-1.5 text-xs text-brand-text/80">
<span className="truncate">
{t("chat.composer.replyTo", {
snippet: (replyTo.content ?? "").slice(0, 60),
})}
</span>
<button
type="button"
onClick={onCancelReply}
className="rounded-full p-1 hover:bg-brand-lines/10"
aria-label={t("chat.composer.cancelReply")}
>
<FiX size={12} />
</button>
</div>
)}
<div className="flex items-end gap-2">
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
notifyTyping();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
disabled={disabled}
rows={1}
placeholder={t("chat.composer.placeholder")}
className="min-h-[42px] max-h-[160px] flex-1 resize-none rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none focus:border-brand-accent"
/>
<Button
type="submit"
disabled={disabled || !text.trim()}
leftIcon={<FiSend size={14} />}
>
{t("common:send", { defaultValue: "Odeslat" })}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,46 @@
import type { PostContent } from "@/api/generated/private/models/postContent";
interface Props {
items: readonly PostContent[];
}
export default function MediaGallery({ items }: Props) {
if (!items?.length) return null;
const layoutClass =
items.length === 1
? "grid-cols-1"
: items.length === 2
? "grid-cols-2"
: "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>
);
}
function MediaItem({ item }: { item: PostContent }) {
const url = 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"
/>
);
}
return (
<img
src={url}
alt={item.alt_text ?? ""}
className="w-full max-h-[480px] object-cover bg-brand-bg/60"
loading="lazy"
/>
);
}

View File

@@ -1 +1,177 @@
/* POST container */
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiTrash2, FiMoreHorizontal } from "react-icons/fi";
import type { Post } from "@/api/generated/private/models/post";
import Avatar from "@/components/ui/Avatar";
import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/context/AuthContext";
import { canDeletePost, canEditPost } from "@/hooks/usePermissions";
import { formatRelative } from "@/utils/relativeTime";
import { apiSocialPostsDestroy } from "@/api/generated/private/posts/posts";
import { useQueryClient } from "@tanstack/react-query";
import MediaGallery from "./MediaGallery";
import PostActions from "./PostActions";
// Extended until orval regeneration adds these fields to the generated Post type
interface AuthorDetail {
id: number;
username: string;
first_name: string;
last_name: string;
avatar: string | null;
}
type EnrichedPost = Post & {
author_detail?: AuthorDetail;
vote_score?: number;
user_vote?: -1 | 0 | 1;
reply_count?: number;
};
interface Props {
post: EnrichedPost;
variant?: "compact" | "default" | "focused";
clickable?: boolean;
onReplyClick?: () => void;
}
export default function Post({
post,
variant = "default",
clickable = true,
onReplyClick,
}: Props) {
const { t } = useTranslation("social");
const { user } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isFocused = variant === "focused";
const isCompact = variant === "compact";
const author = post.author_detail;
const displayName = author?.username ?? `user${post.author}`;
const fullName =
author && (author.first_name || author.last_name)
? `${author.first_name} ${author.last_name}`.trim()
: displayName;
async function handleDelete(e: React.MouseEvent) {
e.stopPropagation();
if (!confirm("Smazat příspěvek?")) return;
await apiSocialPostsDestroy(post.id);
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
}
function open(e: React.MouseEvent) {
if (!clickable) return;
const target = e.target as HTMLElement;
if (target.closest("button, a")) return;
navigate(`/social/post/${post.id}`);
}
const canDelete = canDeletePost(user, post);
const canEdit = canEditPost(user, post);
return (
<article
onClick={open}
className={[
"border-b border-brand-lines/10 px-4 py-3",
isFocused ? "bg-brand-lines/5" : "",
clickable ? "cursor-pointer hover:bg-brand-lines/5 transition-colors" : "",
].join(" ")}
>
<div className="flex gap-3">
<Link
to={`/social/profile/${author?.id ?? post.author}`}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
<Avatar
name={fullName}
src={author?.avatar ?? null}
size={isCompact ? 32 : 40}
/>
</Link>
<div className="min-w-0 flex-1">
<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}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold text-brand-text hover:underline"
>
@{displayName}
</Link>
{post.hub != null && (
<Link
to={`/social/hub/${post.hub}`}
onClick={(e) => e.stopPropagation()}
className="rounded-full bg-brand-boxes/40 px-2 py-0.5 text-xs text-brand-text hover:bg-brand-boxes/60"
>
{t("hub.badge")}
</Link>
)}
<span className="text-brand-text/50">·</span>
<time className="text-xs text-brand-text/60" dateTime={String(post.created_at)}>
{formatRelative(post.created_at)}
</time>
</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>
)}
</header>
{post.content && (
<p className={`mt-1 whitespace-pre-wrap break-words text-brand-text ${isFocused ? "text-lg" : "text-[15px]"}`}>
{post.content}
</p>
)}
{post.contents?.length > 0 && <MediaGallery items={post.contents} />}
{post.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<span
key={tag.id}
className="rounded-full border border-brand-lines/20 bg-brand-lines/5 px-2 py-0.5 text-[11px] text-brand-text/80"
style={{ borderColor: tag.color ?? undefined }}
>
{tag.name}
</span>
))}
</div>
)}
{!isCompact && (
<PostActions
postId={post.id}
replyCount={post.reply_count}
voteScore={post.vote_score}
initialUserVote={post.user_vote}
onReplyClick={onReplyClick}
/>
)}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiMessageSquare, FiArrowUp, FiArrowDown, FiShare2 } from "react-icons/fi";
import IconButton from "@/components/ui/IconButton";
import { apiSocialPostsVoteCreate } from "@/api/generated/private/posts/posts";
import SharePopup from "./SharePopup";
interface Props {
postId: number;
replyCount?: number;
voteScore?: number;
initialUserVote?: -1 | 0 | 1;
onReplyClick?: () => void;
}
export default function PostActions({
postId,
replyCount,
voteScore,
initialUserVote,
onReplyClick,
}: Props) {
const { t } = useTranslation("social");
const [vote, setVote] = useState<-1 | 0 | 1>(initialUserVote ?? 0);
const [score, setScore] = useState(voteScore ?? 0);
const [busy, setBusy] = useState(false);
const [showShare, setShowShare] = useState(false);
const [upHover, setUpHover] = useState(false);
const [downHover, setDownHover] = useState(false);
async function castVote(value: 1 | -1) {
if (busy) return;
setBusy(true);
try {
const next: -1 | 0 | 1 = vote === value ? 0 : value;
const delta = next - vote;
setScore((s) => s + delta);
setVote(next);
await apiSocialPostsVoteCreate(postId, { vote: value } as never);
} finally {
setBusy(false);
}
}
const upActive = vote === 1;
const downActive = vote === -1;
return (
<>
<div className="mt-3 flex items-center gap-3 text-brand-text/70">
{/* Reply */}
<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"
aria-label={t("post.actions.reply")}
title={t("post.actions.reply")}
>
<FiMessageSquare size={16} />
{typeof replyCount === "number" && replyCount > 0 && (
<span className="text-xs tabular-nums">{replyCount}</span>
)}
</button>
{/* 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)}
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",
"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}
style={{ color: upActive ? "rgb(12 74 110)" : undefined }}
className="relative z-10 text-brand-text/50"
/>
</button>
<span
className={[
"min-w-[2rem] px-1.5 text-center text-xs tabular-nums transition-colors duration-200",
upActive || downActive ? "!text-sky-500" : "text-brand-text/50",
].join(" ")}
>
{score}
</span>
{/* Downvote — same pseudo-element trick */}
<button
type="button"
disabled={busy}
onClick={() => 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",
"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}
style={{ color: downActive ? "rgb(12 74 110)" : undefined }}
className="relative z-10 text-brand-text/50"
/>
</button>
</div>
{/* Share */}
<IconButton
icon={<FiShare2 size={16} />}
label={t("post.actions.share")}
onClick={() => setShowShare(true)}
/>
</div>
{showShare && (
<SharePopup postId={postId} onClose={() => setShowShare(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,190 @@
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 { useQueryClient } from "@tanstack/react-query";
import Textarea from "@/components/ui/Textarea";
import Button from "@/components/ui/Button";
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;
onPosted?: () => void;
}
interface ComposerForm {
content: string;
hub: number | null;
}
export default function PostComposer({ parentId, defaultHubId, 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 },
});
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>) {
const picked = Array.from(e.target.files ?? []);
const newPreviews = picked.map((f) => URL.createObjectURL(f));
setFiles((prev) => [...prev, ...picked]);
setPreviews((prev) => [...prev, ...newPreviews]);
e.target.value = "";
}
function removeFile(index: number) {
URL.revokeObjectURL(previews[index]);
setFiles((prev) => prev.filter((_, i) => i !== index));
setPreviews((prev) => prev.filter((_, i) => i !== index));
}
async function onSubmit(values: ComposerForm) {
setRootError(undefined);
clearErrors();
try {
const created = await apiSocialPostsCreate({
content: values.content,
hub: values.hub ?? null,
reply_to: parentId ?? null,
} as Parameters<typeof apiSocialPostsCreate>[0]);
// Upload each file to the new post
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
await privateApi.post(`/api/social/posts/${created.id}/media/`, fd);
}
previews.forEach((url) => URL.revokeObjectURL(url));
setFiles([]);
setPreviews([]);
reset({ content: "", hub: defaultHubId ?? null });
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
onPosted?.();
} catch (err) {
setRootError(applyServerErrors(form, err));
}
}
function onInvalid() {
setRootError(undefined);
}
const hasContent = !!content?.trim() || files.length > 0;
return (
<form
onSubmit={handleSubmit(onSubmit, onInvalid)}
className="border-b border-brand-lines/10 px-4 py-3"
noValidate
>
<FormErrorBanner message={rootError} className="mb-2" />
<Textarea
placeholder={
parentId
? t("post.compose.replyPlaceholder")
: t("post.compose.placeholder")
}
rows={3}
disabled={isSubmitting}
error={errors.content?.message}
{...register("content", {
validate: (v) => files.length > 0 || v.trim().length > 0 || true,
})}
/>
{/* Image 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>
))}
</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"
disabled={isSubmitting}
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
aria-label={t("post.compose.attachImage")}
title={t("post.compose.attachImage")}
>
<FiImage size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
</div>
<Button
type="submit"
disabled={isSubmitting || !hasContent}
leftIcon={isSubmitting ? <Spinner size={14} /> : <FiSend size={14} />}
>
{isSubmitting
? t("post.compose.submitting")
: t("post.compose.submit")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FiX, FiLink, FiShare2, FiCheck } from "react-icons/fi";
interface Props {
postId: number;
onClose: () => void;
}
export default function SharePopup({ postId, onClose }: Props) {
const { t } = useTranslation("social");
const url = `${window.location.origin}/social/post/${postId}`;
const [copied, setCopied] = useState(false);
async function copyLink() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback for browsers without clipboard API
const ta = document.createElement("textarea");
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
async function nativeShare() {
await navigator.share({ url, title: t("post.share.nativeTitle") });
}
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className="relative w-full max-w-sm rounded-2xl border border-brand-lines/20 bg-brand-bg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-brand-lines/10 px-5 py-4">
<h3 className="font-semibold text-brand-text">{t("post.share.title")}</h3>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text transition-colors"
aria-label={t("post.share.close")}
>
<FiX size={16} />
</button>
</div>
{/* URL preview */}
<div className="px-5 pt-4 pb-2">
<div className="flex items-center gap-2 rounded-xl border border-brand-lines/20 bg-brand-bgLight/30 px-3 py-2.5">
<FiLink size={14} className="shrink-0 text-brand-text/40" />
<span className="truncate text-xs text-brand-text/60">{url}</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 px-5 pb-5 pt-3">
<button
type="button"
onClick={copyLink}
className={[
"flex items-center gap-3 rounded-xl border px-4 py-3 text-sm font-medium transition-colors",
copied
? "border-green-500/40 bg-green-500/10 text-green-400"
: "border-brand-lines/20 bg-brand-boxes/20 text-brand-text hover:bg-brand-boxes/40",
].join(" ")}
>
{copied ? <FiCheck size={16} /> : <FiLink size={16} />}
{copied ? t("post.share.copied") : t("post.share.copyLink")}
</button>
{typeof navigator.share === "function" && (
<button
type="button"
onClick={nativeShare}
className="flex items-center gap-3 rounded-xl border border-brand-lines/20 bg-brand-boxes/20 px-4 py-3 text-sm font-medium text-brand-text hover:bg-brand-boxes/40 transition-colors"
>
<FiShare2 size={16} />
{t("post.share.shareVia")}
</button>
)}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,35 @@
interface Props {
name?: string | null;
src?: string | null;
size?: number;
className?: string;
}
function initialsOf(name?: string | null): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export default function Avatar({ name, src, size = 40, className = "" }: Props) {
const dim = { width: size, height: size };
if (src) {
return (
<img
src={src}
alt={name ?? ""}
style={dim}
className={`rounded-full object-cover border border-brand-lines/20 ${className}`}
/>
);
}
return (
<div
style={dim}
className={`rounded-full bg-brand-boxes/70 text-brand-text flex items-center justify-center font-semibold border border-brand-lines/20 ${className}`}
>
<span style={{ fontSize: Math.max(10, size * 0.4) }}>{initialsOf(name)}</span>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type Variant = "primary" | "ghost" | "danger" | "secondary";
type Size = "sm" | "md" | "lg";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
fullWidth?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loading?: boolean;
}
const variantClass: Record<Variant, string> = {
primary:
"bg-brand-accent text-brand-bg hover:brightness-110 disabled:bg-brand-lines/20 disabled:text-brand-text/40",
secondary:
"bg-brand-boxes/40 text-brand-text hover:bg-brand-boxes/60 disabled:opacity-50",
ghost:
"bg-transparent text-brand-text hover:bg-brand-lines/10 disabled:opacity-50 border-transparent",
danger:
"bg-red-600/80 text-white hover:bg-red-600 disabled:opacity-50",
};
const sizeClass: Record<Size, string> = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-5 py-3 text-base",
};
export default function Button({
variant = "primary",
size = "md",
fullWidth,
leftIcon,
rightIcon,
loading,
className = "",
children,
disabled,
...rest
}: Props) {
return (
<button
{...rest}
disabled={disabled || loading}
className={[
"inline-flex items-center justify-center gap-2 rounded-xl font-medium",
"border border-brand-lines/20 transition-[transform,box-shadow,background] duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent/60",
"disabled:cursor-not-allowed",
"glow",
variantClass[variant],
sizeClass[size],
fullWidth ? "w-full" : "",
className,
].join(" ")}
>
{leftIcon}
{children}
{rightIcon}
</button>
);
}

View File

@@ -0,0 +1,28 @@
import type { HTMLAttributes } from "react";
interface Props extends HTMLAttributes<HTMLDivElement> {
padded?: boolean;
hoverable?: boolean;
}
export default function Card({
padded = true,
hoverable = false,
className = "",
children,
...rest
}: Props) {
return (
<div
{...rest}
className={[
"glass border border-brand-lines/15 rounded-2xl",
padded ? "p-4" : "",
hoverable ? "transition-colors hover:border-brand-accent/40" : "",
className,
].join(" ")}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
error?: string;
}
const Checkbox = forwardRef<HTMLInputElement, Props>(function Checkbox(
{ label, error, className = "", id, ...rest },
ref,
) {
const inputId = id ?? rest.name;
return (
<div>
<label className="flex items-start gap-2 cursor-pointer" htmlFor={inputId}>
<input
{...rest}
ref={ref}
id={inputId}
type="checkbox"
className={[
"mt-0.5 h-4 w-4 rounded border border-brand-lines/40",
"bg-brand-bgLight/40 text-brand-accent",
"focus:outline-none focus:ring-2 focus:ring-brand-accent/40",
"accent-brand-accent",
error ? "border-red-500/60" : "",
className,
].join(" ")}
/>
{label && (
<span className="text-sm text-brand-text/90 select-none">{label}</span>
)}
</label>
{error && <span className="mt-1 block text-xs text-red-400">{error}</span>}
</div>
);
});
export default Checkbox;

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
interface Props {
icon?: ReactNode;
title?: string;
message?: string;
action?: ReactNode;
className?: string;
}
export default function EmptyState({
icon,
title,
message,
action,
className = "",
}: Props) {
return (
<div
className={`flex flex-col items-center justify-center text-center px-6 py-12 text-brand-text/70 ${className}`}
>
{icon && <div className="mb-3 text-brand-accent text-3xl">{icon}</div>}
{title && <h3 className="text-lg font-semibold text-brand-text mb-1">{title}</h3>}
{message && <p className="max-w-sm text-sm">{message}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { FiAlertCircle } from "react-icons/fi";
interface Props {
message?: string | null;
className?: string;
}
export default function FormErrorBanner({ message, className = "" }: Props) {
if (!message) return null;
return (
<div
role="alert"
className={[
"flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/10",
"px-4 py-3 text-sm text-red-300 whitespace-pre-line",
className,
].join(" ")}
>
<FiAlertCircle className="mt-0.5 shrink-0" />
<span>{message}</span>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
active?: boolean;
}
export default function IconButton({
icon,
label,
active,
className = "",
...rest
}: Props) {
return (
<button
{...rest}
type={rest.type ?? "button"}
aria-label={label}
title={label}
className={[
"inline-flex items-center justify-center w-9 h-9 rounded-full",
"border border-transparent text-brand-text/80",
"hover:bg-brand-lines/10 hover:text-brand-accent transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent/60",
active ? "text-brand-accent bg-brand-lines/10" : "",
className,
].join(" ")}
>
{icon}
</button>
);
}

View File

@@ -0,0 +1,42 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
interface Props extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
icon?: ReactNode;
error?: string;
}
const Input = forwardRef<HTMLInputElement, Props>(function Input(
{ label, icon, error, className = "", id, ...rest },
ref,
) {
const inputId = id ?? rest.name;
return (
<label className="block" htmlFor={inputId}>
{label && (
<span className="mb-1.5 flex items-center gap-2 text-sm font-medium text-brand-text/90">
{icon}
{label}
</span>
)}
<input
{...rest}
ref={ref}
id={inputId}
className={[
"w-full rounded-xl px-3.5 py-2.5",
"bg-brand-bgLight/40 border border-brand-lines/25 text-brand-text",
"placeholder:text-brand-text/40",
"focus:outline-none focus:border-brand-accent focus:ring-2 focus:ring-brand-accent/30",
"disabled:opacity-60 disabled:cursor-not-allowed",
error ? "border-red-500/60" : "",
className,
].join(" ")}
/>
{error && <span className="mt-1 block text-xs text-red-400">{error}</span>}
</label>
);
});
export default Input;

View File

@@ -0,0 +1,15 @@
import { FiLoader } from "react-icons/fi";
interface Props {
size?: number;
className?: string;
}
export default function Spinner({ size = 20, className = "" }: Props) {
return (
<FiLoader
className={`animate-spin text-brand-accent ${className}`}
style={{ width: size, height: size }}
/>
);
}

View File

@@ -0,0 +1,40 @@
import type { TextareaHTMLAttributes } from "react";
import { forwardRef } from "react";
interface Props extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
const Textarea = forwardRef<HTMLTextAreaElement, Props>(function Textarea(
{ label, error, className = "", id, ...rest },
ref,
) {
const fieldId = id ?? rest.name;
return (
<label className="block" htmlFor={fieldId}>
{label && (
<span className="mb-1.5 block text-sm font-medium text-brand-text/90">
{label}
</span>
)}
<textarea
{...rest}
ref={ref}
id={fieldId}
className={[
"w-full rounded-xl px-3.5 py-2.5 resize-y",
"bg-brand-bgLight/40 border border-brand-lines/25 text-brand-text",
"placeholder:text-brand-text/40",
"focus:outline-none focus:border-brand-accent focus:ring-2 focus:ring-brand-accent/30",
"disabled:opacity-60 disabled:cursor-not-allowed",
error ? "border-red-500/60" : "",
className,
].join(" ")}
/>
{error && <span className="mt-1 block text-xs text-red-400">{error}</span>}
</label>
);
});
export default Textarea;

View File

@@ -2,11 +2,9 @@ import type { ReactNode } from "react";
import { createContext, useContext, useState, useEffect } from "react";
// Import z Orval generovaného API
import {
apiAccountLoginCreate,
apiAccountLogoutCreate
} from "@/api/generated/public/account";
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";
@@ -48,18 +46,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
async function login(payload: CustomTokenObtainPair) {
setIsLoading(true);
try {
// Login endpoint automaticky nastaví HttpOnly cookies
await apiAccountLoginCreate(payload);
// Po úspěšném přihlášení načti informace o uživateli
await refreshUser();
} catch (err: any) {
setIsLoading(false);
const errorMessage = err.response?.data?.error || err.message;
throw new Error(errorMessage);
}
// 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();
}
async function logout() {

View File

@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getChatSocketUrl } from "@/api/social/ws";
export type ChatSocketStatus = "idle" | "connecting" | "open" | "closed" | "error";
export type ChatSocketEvent =
| { type: "new_chat_message"; message_id: number; message: string; sender: string }
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender: string }
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
| { type: "delete_chat_message"; message_id: number }
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
| { type: "typing"; user: string; is_typing: boolean }
| { type: "stop_typing"; user: string }
| { type: "error"; error: string };
interface Opts {
chatId: number | null;
onEvent?: (event: ChatSocketEvent) => void;
}
const MAX_BACKOFF_MS = 15_000;
export function useChatSocket({ chatId, onEvent }: Opts) {
const [status, setStatus] = useState<ChatSocketStatus>("idle");
const [lastEvent, setLastEvent] = useState<ChatSocketEvent | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const closedByUserRef = useRef(false);
const onEventRef = useRef(onEvent);
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
useEffect(() => {
if (chatId == null || !Number.isFinite(chatId)) return;
closedByUserRef.current = false;
let reconnectTimer: number | undefined;
function connect() {
setStatus("connecting");
const ws = new WebSocket(getChatSocketUrl(chatId!));
wsRef.current = ws;
ws.addEventListener("open", () => {
setStatus("open");
reconnectAttemptRef.current = 0;
});
ws.addEventListener("message", (e) => {
try {
const data = JSON.parse(e.data) as ChatSocketEvent;
setLastEvent(data);
onEventRef.current?.(data);
} catch {
// swallow malformed payloads
}
});
ws.addEventListener("error", () => setStatus("error"));
ws.addEventListener("close", () => {
setStatus("closed");
if (closedByUserRef.current) return;
const attempt = reconnectAttemptRef.current++;
const backoff = Math.min(500 * 2 ** attempt, MAX_BACKOFF_MS);
reconnectTimer = window.setTimeout(connect, backoff);
});
}
connect();
return () => {
closedByUserRef.current = true;
if (reconnectTimer) window.clearTimeout(reconnectTimer);
wsRef.current?.close();
wsRef.current = null;
};
}, [chatId]);
const send = useCallback((payload: object) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
return true;
}
return false;
}, []);
const sendMessage = useCallback(
(message: string) => send({ type: "new_chat_message", message }),
[send],
);
const sendReply = useCallback(
(message: string, replyToId: number) =>
send({ type: "new_reply_chat_message", message, reply_to_id: replyToId }),
[send],
);
const sendReaction = useCallback(
(messageId: number, emoji: string) =>
send({ type: "reaction", message_id: messageId, emoji }),
[send],
);
const sendTyping = useCallback(
(isTyping: boolean) =>
send({ type: isTyping ? "typing" : "stop_typing", is_typing: isTyping }),
[send],
);
return {
status,
lastEvent,
sendMessage,
sendReply,
sendReaction,
sendTyping,
};
}

View File

@@ -0,0 +1,39 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import {
apiSocialMessagesCursor,
messagesQueryKey,
} from "@/api/social/feed";
function extractCursor(nextUrl: string | null): string | null {
if (!nextUrl) return null;
try {
const url = new URL(nextUrl, "http://placeholder");
return url.searchParams.get("cursor");
} catch {
return null;
}
}
interface Opts {
chatId: number;
enabled?: boolean;
}
export function useInfiniteMessages({ chatId, enabled = true }: Opts) {
const query = useInfiniteQuery({
queryKey: messagesQueryKey(chatId),
enabled: enabled && Number.isFinite(chatId),
initialPageParam: null as string | null,
queryFn: ({ pageParam, signal }) =>
apiSocialMessagesCursor(
{ chat: chatId, cursor: pageParam ?? undefined },
signal,
),
getNextPageParam: (last) => extractCursor(last.next),
});
// Backend returns newest-first; reverse for chronological render.
const messages = (query.data?.pages.flatMap((p) => p.results) ?? []).slice().reverse();
return { ...query, messages };
}

View File

@@ -0,0 +1,59 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import {
apiSocialPostReplies,
apiSocialPostsFeed,
feedQueryKey,
repliesQueryKey,
type FeedStrategy,
} from "@/api/social/feed";
function extractCursor(nextUrl: string | null): string | null {
if (!nextUrl) return null;
try {
const url = new URL(nextUrl, "http://placeholder");
return url.searchParams.get("cursor");
} catch {
return null;
}
}
interface FeedOpts {
strategy?: FeedStrategy;
enabled?: boolean;
}
export function useInfinitePosts({ strategy = "recent", enabled = true }: FeedOpts = {}) {
const query = useInfiniteQuery({
queryKey: feedQueryKey({ feed_strategy: strategy }),
enabled,
initialPageParam: null as string | null,
queryFn: ({ pageParam, signal }) =>
apiSocialPostsFeed(
{ cursor: pageParam ?? undefined, feed_strategy: strategy },
signal,
),
getNextPageParam: (last) => extractCursor(last.next),
});
const posts = query.data?.pages.flatMap((p) => p.results) ?? [];
return { ...query, posts };
}
interface RepliesOpts {
postId: number;
enabled?: boolean;
}
export function useInfiniteReplies({ postId, enabled = true }: RepliesOpts) {
const query = useInfiniteQuery({
queryKey: repliesQueryKey(postId),
enabled: enabled && Number.isFinite(postId),
initialPageParam: null as string | null,
queryFn: ({ pageParam, signal }) =>
apiSocialPostReplies({ reply_to: postId, cursor: pageParam ?? undefined }, signal),
getNextPageParam: (last) => extractCursor(last.next),
});
const replies = query.data?.pages.flatMap((p) => p.results) ?? [];
return { ...query, replies };
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef } from "react";
interface Options {
enabled?: boolean;
rootMargin?: string;
threshold?: number;
}
/**
* Calls `onIntersect` once whenever the returned ref scrolls into view.
* Drives infinite scrolling — attach the ref to a sentinel `<div>` at the
* bottom of a list.
*/
export function useIntersectionLoader<T extends HTMLElement = HTMLDivElement>(
onIntersect: () => void,
{ enabled = true, rootMargin = "200px", threshold = 0 }: Options = {},
) {
const sentinelRef = useRef<T | null>(null);
useEffect(() => {
if (!enabled) return;
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
onIntersect();
}
},
{ rootMargin, threshold },
);
observer.observe(node);
return () => observer.disconnect();
}, [enabled, rootMargin, threshold, onIntersect]);
return sentinelRef;
}

View File

@@ -0,0 +1,60 @@
import type { CustomUser } from "@/api/generated/private/models/customUser";
import type { Post } from "@/api/generated/private/models/post";
import type { Chat } from "@/api/generated/private/models/chat";
import type { Message } from "@/api/generated/private/models/message";
/**
* Frontend permission inference. Mirrors backend permission classes so the UI
* hides actions the user cannot perform — this is a UX guard, NOT a security
* boundary. The backend remains the source of truth and will return 403.
*/
function isSuperuser(user: CustomUser | null): boolean {
// CustomUser shape does not currently expose is_superuser; treat as false.
// If a role-based check becomes useful, extend here.
void user;
return false;
}
export function canEditPost(user: CustomUser | null, post: Post): boolean {
if (!user) return false;
return user.id === post.author;
}
export function canDeletePost(
user: CustomUser | null,
post: Post,
ctx?: { hubOwnerId?: number | null; isHubModerator?: boolean },
): boolean {
if (!user) return false;
if (user.id === post.author) return true;
if (isSuperuser(user)) return true;
if (ctx?.hubOwnerId && ctx.hubOwnerId === user.id) return true;
if (ctx?.isHubModerator) return true;
return false;
}
export function canEditMessage(user: CustomUser | null, message: Message): boolean {
if (!user || message.sender == null) return false;
return user.id === message.sender;
}
export function canDeleteMessage(
user: CustomUser | null,
message: Message,
chat?: Chat | null,
): boolean {
if (!user) return false;
if (message.sender != null && user.id === message.sender) return true;
if (isSuperuser(user)) return true;
if (chat?.owner === user.id) return true;
if (chat?.moderators?.includes(user.id)) return true;
return false;
}
export function canManageChat(user: CustomUser | null, chat: Chat | null): boolean {
if (!user || !chat) return false;
if (chat.owner === user.id) return true;
if (isSuperuser(user)) return true;
return chat.moderators?.includes(user.id) ?? false;
}

View File

@@ -0,0 +1,24 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import csCommon from "./locales/cs/common.json";
import csSocial from "./locales/cs/social.json";
import csAuth from "./locales/cs/auth.json";
void i18n.use(initReactI18next).init({
resources: {
cs: {
common: csCommon,
social: csSocial,
auth: csAuth,
},
},
lng: "cs",
fallbackLng: "cs",
defaultNS: "common",
ns: ["common", "social", "auth"],
interpolation: { escapeValue: false },
returnNull: false,
});
export default i18n;

View File

@@ -0,0 +1,54 @@
{
"login": {
"title": "Přihlášení",
"subtitle": "Vítejte zpět",
"usernameLabel": "Email nebo uživatelské jméno",
"usernamePlaceholder": "email@example.com nebo username",
"passwordLabel": "Heslo",
"passwordPlaceholder": "••••••••",
"submit": "Přihlásit se",
"submitting": "Přihlašování...",
"forgot": "Zapomenuté heslo?",
"noAccount": "Ještě nemáte účet?",
"registerCta": "Zaregistrujte se",
"errors": {
"missing": "Vyplňte prosím všechna pole",
"generic": "Přihlášení se nezdařilo"
}
},
"register": {
"title": "Registrace",
"subtitle": "Vytvořte si účet",
"usernameLabel": "Uživatelské jméno",
"emailLabel": "Email",
"passwordLabel": "Heslo",
"password2Label": "Potvrďte heslo",
"gdprLabel": "Souhlasím se zpracováním osobních údajů (GDPR)",
"submit": "Zaregistrovat se",
"submitting": "Vytváření účtu...",
"passwordHint": "Min. 8 znaků, velké + malé písmeno, číslice.",
"haveAccount": "Již máte účet?",
"loginCta": "Přihlaste se",
"successTitle": "Registrace úspěšná!",
"successBody": "Účet byl vytvořen. Přesměrování na přihlášení...",
"optionalToggle": "Doplňující údaje (volitelné)",
"fields": {
"username": "Uživatelské jméno",
"firstName": "Křestní jméno",
"lastName": "Příjmení",
"phone": "Telefonní číslo",
"city": "Město",
"street": "Ulice",
"postalCode": "PSČ"
},
"errors": {
"emailRequired": "Vyplňte prosím email",
"passwordRequired": "Vyplňte prosím heslo",
"password2Required": "Potvrďte prosím heslo",
"mismatch": "Hesla se neshodují",
"tooShort": "Heslo musí mít alespoň 8 znaků",
"gdprRequired": "Pro registraci musíte souhlasit se zpracováním údajů",
"generic": "Registrace se nezdařila"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"loading": "Načítání...",
"error": "Něco se nepovedlo",
"retry": "Zkusit znovu",
"cancel": "Zrušit",
"save": "Uložit",
"delete": "Smazat",
"edit": "Upravit",
"send": "Odeslat",
"search": "Vyhledat",
"back": "Zpět",
"more": "Více",
"you": "Vy",
"now": "teď",
"appName": "vontor.cz"
}

View File

@@ -0,0 +1,95 @@
{
"nav": {
"feed": "Feed",
"chats": "Zprávy",
"hubs": "Huby",
"profile": "Profil",
"logout": "Odhlásit"
},
"feed": {
"title": "Feed",
"empty": "Zatím tu nic není. Sledujte huby nebo napište první příspěvek.",
"loadingMore": "Načítání dalších příspěvků..."
},
"post": {
"compose": {
"placeholder": "Co se vám honí hlavou?",
"submit": "Zveřejnit",
"submitting": "Odesílání...",
"replyPlaceholder": "Odpovědět na příspěvek",
"hubLabel": "Hub (volitelně)",
"noHub": "Bez hubu",
"attachImage": "Přidat obrázek",
"removeImage": "Odebrat obrázek"
},
"share": {
"title": "Sdílet příspěvek",
"close": "Zavřít",
"copyLink": "Kopírovat odkaz",
"copied": "Zkopírováno!",
"shareVia": "Sdílet přes...",
"nativeTitle": "Příspěvek"
},
"actions": {
"reply": "Odpovědět",
"upvote": "Plus",
"downvote": "Mínus",
"more": "Více možností",
"delete": "Smazat",
"edit": "Upravit",
"share": "Sdílet"
},
"thread": {
"parents": "Vlákno výše",
"replies": "Odpovědi",
"noReplies": "Buďte první, kdo odpoví."
},
"detail": {
"back": "Zpět na feed"
},
"meta": {
"edited": "(upraveno)",
"inHub": "v {{name}}"
}
},
"chat": {
"sidebar": {
"title": "Konverzace",
"search": "Najít konverzaci",
"empty": "Zatím žádné konverzace.",
"new": "Nová konverzace"
},
"room": {
"selectChat": "Vyberte konverzaci nalevo",
"typing": "{{user}} píše...",
"typingMany": "Více lidí píše...",
"edited": "upraveno",
"disconnected": "Spojení přerušeno, obnovuji...",
"loadingHistory": "Načítání starších zpráv...",
"noMessages": "Žádné zprávy. Pošlete první!"
},
"composer": {
"placeholder": "Napište zprávu...",
"attach": "Přidat soubor",
"replyTo": "Odpověď: {{snippet}}",
"cancelReply": "Zrušit odpověď"
},
"actions": {
"reply": "Odpovědět",
"react": "Přidat reakci",
"more": "Více možností",
"delete": "Smazat zprávu",
"edit": "Upravit zprávu"
}
},
"hub": {
"badge": "Hub"
},
"profile": {
"back": "Zpět",
"notFound": "Profil nenalezen",
"joined": "Člen od {{date}}",
"noPosts": "Zatím žádné příspěvky.",
"editProfile": "Upravit profil"
}
}

View File

@@ -1,13 +1,13 @@
import { Outlet } from "react-router-dom";
import ChatSidebar from "@/components/social/chat/ChatSidebar";
export default function ChatLayout() {
return (
<div className="min-h-screen flex flex-col">
<header className="bg-gray-800 text-white p-4">
<h1 className="text-xl font-bold">Chat</h1>
</header>
<main className="flex-1 p-4">
nothing now
</main>
<div className="grid h-[calc(100vh-0px)] grid-cols-[280px_1fr]">
<ChatSidebar />
<section className="flex h-full flex-col overflow-hidden">
<Outlet />
</section>
</div>
);
}
}

View File

@@ -0,0 +1,97 @@
import { NavLink, Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
FiHome,
FiMessageCircle,
FiUsers,
FiUser,
FiLogOut,
} from "react-icons/fi";
import { useAuth } from "@/context/AuthContext";
import Avatar from "@/components/ui/Avatar";
interface NavItem {
to: string;
icon: React.ReactNode;
labelKey: string;
}
function buildItems(userId?: number): 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: userId ? `/social/profile/${userId}` : "/social/profile",
icon: <FiUser size={22} />,
labelKey: "nav.profile",
},
];
}
export default function SocialLayout() {
const { t } = useTranslation("social");
const { user } = useAuth();
const items = buildItems(user?.id);
return (
<div className="min-h-screen w-full">
<div className="mx-auto flex max-w-[1280px] gap-6 px-3 sm:px-6">
{/* Left rail */}
<aside className="sticky top-0 hidden h-screen w-[72px] flex-shrink-0 flex-col items-center justify-between py-6 md:flex md:w-[220px] md:items-start">
<div className="flex w-full flex-col gap-1.5">
<div className="mb-4 px-2 text-xl font-bold text-rainbow hidden md:block">
vontor
</div>
{items.map((it) => (
<NavLink
key={it.to}
to={it.to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-2xl px-3 py-2.5 transition-colors",
"text-brand-text/80 hover:bg-brand-lines/10 hover:text-brand-text",
isActive ? "bg-brand-lines/10 text-brand-accent font-semibold" : "",
].join(" ")
}
>
{it.icon}
<span className="hidden md:inline">{t(it.labelKey)}</span>
</NavLink>
))}
</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} />
<div className="hidden min-w-0 md:block">
<div className="truncate text-sm font-semibold text-brand-text">
{user?.username ?? "—"}
</div>
<NavLink
to="/social/logout"
className="flex items-center gap-1 text-xs text-brand-text/60 hover:text-brand-accent"
>
<FiLogOut size={12} /> {t("nav.logout")}
</NavLink>
</div>
</div>
</aside>
{/* Main column */}
<main className="min-h-screen flex-1 border-x border-brand-lines/15 max-w-[640px]">
<Outlet />
</main>
{/* Right rail (placeholder; hidden on small screens) */}
<aside className="sticky top-0 hidden h-screen w-[300px] flex-shrink-0 py-6 lg:block">
<div className="glass rounded-2xl p-4 text-sm text-brand-text/70">
<div className="font-semibold text-brand-text mb-2">
{t("nav.hubs")}
</div>
<p className="text-xs"></p>
</div>
</aside>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from './context/AuthContext'
import './index.css'
import './i18n'
import App from './App.tsx'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

View File

@@ -0,0 +1,84 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { FiHome } from "react-icons/fi";
import Post from "@/components/social/posts/Post";
import PostComposer from "@/components/social/posts/PostComposer";
import EmptyState from "@/components/ui/EmptyState";
import Spinner from "@/components/ui/Spinner";
import { useInfinitePosts } from "@/hooks/useInfinitePosts";
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
export default function FeedPage() {
const { t } = useTranslation("social");
const navigate = useNavigate();
const {
posts,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfinitePosts();
const sentinelRef = useIntersectionLoader<HTMLDivElement>(
() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
},
{ enabled: hasNextPage && !isLoading },
);
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("feed.title")}</h1>
</header>
<PostComposer />
{isLoading && (
<div className="flex justify-center py-10">
<Spinner size={28} />
</div>
)}
{isError && !isLoading && (
<EmptyState
title={t("common:error", { defaultValue: "Něco se nepovedlo" })}
action={
<button
type="button"
onClick={() => void refetch()}
className="text-brand-accent hover:underline"
>
{t("common:retry", { defaultValue: "Zkusit znovu" })}
</button>
}
/>
)}
{!isLoading && !isError && posts.length === 0 && (
<EmptyState icon={<FiHome />} message={t("feed.empty")} />
)}
<div>
{posts.map((p) => (
<Post
key={p.id}
post={p}
onReplyClick={() => navigate(`/social/post/${p.id}`)}
/>
))}
</div>
{hasNextPage && (
<div
ref={sentinelRef}
className="flex items-center justify-center py-6 text-sm text-brand-text/60"
>
{isFetchingNextPage ? <Spinner size={20} /> : t("feed.loadingMore")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiArrowLeft } from "react-icons/fi";
import { useApiSocialHubsRetrieve } from "@/api/generated/private/hubs/hubs";
import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
import Post from "@/components/social/posts/Post";
import Avatar from "@/components/ui/Avatar";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
export default function HubPage() {
const { t } = useTranslation("social");
const { id } = useParams<{ id: string }>();
const hubId = Number(id);
const { data: hub, isLoading } = useApiSocialHubsRetrieve(String(hubId));
const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList(
Number.isFinite(hubId) ? { hub: hubId } : undefined,
);
const posts = postsData?.results ?? [];
if (isLoading) {
return (
<div className="flex justify-center py-10">
<Spinner size={28} />
</div>
);
}
if (!hub) {
return <EmptyState title="Hub nenalezen" />;
}
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">
<Link
to="/social/hubs"
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"
aria-label={t("common:back", { defaultValue: "Zpět" })}
>
<FiArrowLeft size={20} />
</Link>
<Avatar name={hub.name} src={hub.icon ?? undefined} size={36} />
<div className="min-w-0 flex-1">
<h1 className="truncate text-lg font-bold text-brand-text">{hub.name}</h1>
{hub.description && (
<p className="truncate text-xs text-brand-text/60">{hub.description}</p>
)}
</div>
</header>
{postsLoading && (
<div className="flex justify-center py-6">
<Spinner size={24} />
</div>
)}
{!postsLoading && posts.length === 0 && <EmptyState message="—" />}
{posts.map((p) => (
<Post key={p.id} post={p} />
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiUsers } from "react-icons/fi";
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
import Avatar from "@/components/ui/Avatar";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
export default function HubsPage() {
const { t } = useTranslation("social");
const { data, isLoading } = useApiSocialHubsList(undefined);
const hubs = 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.hubs")}</h1>
</header>
{isLoading && (
<div className="flex justify-center py-10">
<Spinner size={28} />
</div>
)}
{!isLoading && hubs.length === 0 && (
<EmptyState icon={<FiUsers />} message="—" />
)}
<ul>
{hubs.map((hub) => (
<li key={hub.id}>
<Link
to={`/social/hub/${hub.id}`}
className="flex items-center gap-3 border-b border-brand-lines/10 px-4 py-3 hover:bg-brand-lines/5 transition-colors"
>
<Avatar name={hub.name} src={hub.icon ?? undefined} size={40} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-brand-text">
{hub.name}
</div>
{hub.description && (
<div className="truncate text-xs text-brand-text/60">
{hub.description}
</div>
)}
</div>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiArrowLeft } from "react-icons/fi";
import { useApiSocialPostsRetrieve } from "@/api/generated/private/posts/posts";
import Post from "@/components/social/posts/Post";
import PostComposer from "@/components/social/posts/PostComposer";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
import { useInfiniteReplies } from "@/hooks/useInfinitePosts";
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
const MAX_PARENT_DEPTH = 5;
interface ParentChainProps {
parentId: number;
depth: number;
}
function ParentChainItem({ parentId, depth }: ParentChainProps) {
const { data: parent, isLoading } = useApiSocialPostsRetrieve(parentId);
if (isLoading || !parent) return null;
return (
<>
{parent.reply_to && depth < MAX_PARENT_DEPTH && (
<ParentChainItem parentId={parent.reply_to} depth={depth + 1} />
)}
<Link to={`/social/post/${parent.id}`} className="block">
<Post post={parent} variant="compact" clickable={false} />
</Link>
</>
);
}
export default function PostPage() {
const { t } = useTranslation("social");
const { id } = useParams<{ id: string }>();
const postId = Number(id);
const composerRef = useRef<HTMLDivElement>(null);
function focusComposer() {
composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
composerRef.current?.querySelector("textarea")?.focus({ preventScroll: true });
}
const { data: post, isLoading, isError } = useApiSocialPostsRetrieve(postId);
const {
replies,
isLoading: repliesLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteReplies({ postId, enabled: !!postId });
const sentinelRef = useIntersectionLoader<HTMLDivElement>(
() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
},
{ enabled: hasNextPage },
);
if (isLoading) {
return (
<div className="flex justify-center py-10">
<Spinner size={28} />
</div>
);
}
if (isError || !post) {
return <EmptyState title="Příspěvek nenalezen" />;
}
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">
<Link
to="/social/feed"
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"
aria-label={t("post.detail.back")}
>
<FiArrowLeft size={20} />
</Link>
<h1 className="text-lg font-bold text-brand-text">{t("post.detail.back")}</h1>
</header>
{post.reply_to != null && (
<div className="border-b border-brand-lines/10">
<ParentChainItem parentId={post.reply_to} depth={0} />
</div>
)}
<Post post={post} variant="focused" clickable={false} onReplyClick={focusComposer} />
<div ref={composerRef}>
<PostComposer parentId={post.id} defaultHubId={post.hub ?? null} />
</div>
<section>
{repliesLoading && (
<div className="flex justify-center py-6">
<Spinner size={24} />
</div>
)}
{!repliesLoading && replies.length === 0 && (
<EmptyState message={t("post.thread.noReplies")} />
)}
{replies.map((r) => (
<Post key={r.id} post={r} />
))}
{hasNextPage && (
<div
ref={sentinelRef}
className="flex items-center justify-center py-4 text-sm text-brand-text/60"
>
{isFetchingNextPage ? <Spinner size={20} /> : t("feed.loadingMore")}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { FiLogOut, FiUser } from "react-icons/fi";
import { Link } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import Avatar from "@/components/ui/Avatar";
import Card from "@/components/ui/Card";
import EmptyState from "@/components/ui/EmptyState";
export default function ProfilePage() {
const { t } = useTranslation("social");
const { user } = useAuth();
if (!user) return <EmptyState icon={<FiUser />} title="—" />;
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.profile")}</h1>
</header>
<div className="p-4">
<Card className="flex items-center gap-4">
<Avatar name={user.username || user.email} size={64} />
<div className="min-w-0 flex-1">
<div className="truncate text-lg font-semibold text-brand-text">
@{user.username}
</div>
{user.email && (
<div className="truncate text-sm text-brand-text/60">{user.email}</div>
)}
{(user.first_name || user.last_name) && (
<div className="text-sm text-brand-text/70">
{[user.first_name, user.last_name].filter(Boolean).join(" ")}
</div>
)}
</div>
</Card>
<div className="mt-4 flex justify-end">
<Link
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")}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
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 { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
import { useAuth } from "@/context/AuthContext";
import Avatar from "@/components/ui/Avatar";
import Post from "@/components/social/posts/Post";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
function formatJoined(dateVal: Date | string | undefined): string {
if (!dateVal) return "";
const d = new Date(dateVal as string);
return d.toLocaleDateString("cs-CZ", { year: "numeric", month: "long" });
}
export default function UserProfilePage() {
const { id } = useParams<{ id: string }>();
const userId = Number(id);
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: postsData, isLoading: postsLoading } = useApiSocialPostsList(
{ author: userId },
);
const posts = postsData?.results ?? [];
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">
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"
aria-label={t("profile.back")}
>
<FiArrowLeft size={20} />
</button>
<h1 className="text-lg font-bold text-brand-text">
{profileLoading ? "…" : profile ? `@${profile.username}` : t("profile.notFound")}
</h1>
</header>
{profileLoading ? (
<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>
{(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>
)}
</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>
)}
</div>
</div>
{/* Posts */}
<div>
{postsLoading ? (
<div className="flex justify-center py-8">
<Spinner size={22} />
</div>
) : posts.length === 0 ? (
<EmptyState message={t("profile.noPosts")} />
) : (
posts.map((p) => (
<Post
key={p.id}
post={p}
onReplyClick={() => navigate(`/social/post/${p.id}`)}
/>
))
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,224 +1,211 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useAuth } from "@/context/AuthContext";
import { Navigate } from "react-router-dom";
import { FaUser, FaEnvelope, FaPhone, FaMapMarkerAlt, FaCheckCircle, FaSpinner } from "react-icons/fa";
import {
FiUser,
FiPhone,
FiMapPin,
FiCheckCircle,
FiMail,
} from "react-icons/fi";
import { apiAccountUsersPartialUpdate } from "@/api/generated/private/account/account";
import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input";
import Button from "@/components/ui/Button";
import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
interface SettingsForm {
first_name: string;
last_name: string;
phone_number: string;
city: string;
street: string;
postal_code: string;
}
export default function AccountSettings() {
const { user, isAuthenticated, isLoading: authLoading, refreshUser } = useAuth();
const [formData, setFormData] = useState({
first_name: user?.first_name || "",
last_name: user?.last_name || "",
phone_number: user?.phone_number || "",
city: user?.city || "",
street: user?.street || "",
postal_code: user?.postal_code || "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [rootError, setRootError] = useState<string | undefined>();
const [success, setSuccess] = useState(false);
const form = useForm<SettingsForm>({
defaultValues: {
first_name: user?.first_name ?? "",
last_name: user?.last_name ?? "",
phone_number: user?.phone_number ?? "",
city: user?.city ?? "",
street: user?.street ?? "",
postal_code: user?.postal_code ?? "",
},
});
const { register, handleSubmit, formState, reset, clearErrors } = form;
const { errors, isSubmitting } = formState;
// Re-seed form once user data arrives from the auth refresh.
useEffect(() => {
if (user) {
reset({
first_name: user.first_name ?? "",
last_name: user.last_name ?? "",
phone_number: user.phone_number ?? "",
city: user.city ?? "",
street: user.street ?? "",
postal_code: user.postal_code ?? "",
});
}
}, [user, reset]);
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<FaSpinner className="text-blue-500 text-5xl animate-spin" />
<Spinner size={36} />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
return <Navigate to="/social/login" replace />;
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
async function onSubmit(values: SettingsForm) {
setRootError(undefined);
setSuccess(false);
setIsLoading(true);
clearErrors();
if (!user?.id) {
setRootError("User ID not found");
return;
}
try {
if (!user?.id) throw new Error("User ID not found");
await apiAccountUsersPartialUpdate(user.id, formData);
await apiAccountUsersPartialUpdate(
user.id,
values as Parameters<typeof apiAccountUsersPartialUpdate>[1],
);
await refreshUser();
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || "Nepodařilo se uložit změny";
setError(errorMessage);
} finally {
setIsLoading(false);
} catch (err) {
setRootError(applyServerErrors(form, err) ?? "Nepodařilo se uložit změny");
}
}
function onInvalid() {
setRootError(undefined);
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-3xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">Nastavení účtu</h1>
<p className="text-gray-600 mb-8">Upravte své osobní údaje</p>
<div className="min-h-screen p-4">
<div className="mx-auto max-w-3xl space-y-4">
<Card>
<h1 className="text-2xl font-bold text-brand-text">Nastavení účtu</h1>
<p className="mt-1 text-sm text-brand-text/70">Upravte své osobní údaje</p>
</Card>
{/* User info (read-only) */}
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h2 className="font-semibold text-gray-800 mb-2">Informace o účtu</h2>
<div className="space-y-1 text-sm">
<p><strong>Username:</strong> {user?.username}</p>
<p><strong>Email:</strong> {user?.email}</p>
<p><strong>Role:</strong> {user?.role || "user"}</p>
<p><strong>Email ověřen:</strong> {user?.email_verified ? "✅ Ano" : "❌ Ne"}</p>
</div>
</div>
<Card>
<h2 className="mb-3 font-semibold text-brand-text">Informace o účtu</h2>
<dl className="space-y-1 text-sm text-brand-text/80">
<div className="flex gap-2"><dt className="w-32 text-brand-text/60">Username</dt><dd>{user?.username}</dd></div>
<div className="flex gap-2"><dt className="w-32 text-brand-text/60">Email</dt><dd className="flex items-center gap-1"><FiMail size={12} /> {user?.email}</dd></div>
<div className="flex gap-2"><dt className="w-32 text-brand-text/60">Role</dt><dd>{user?.role || "user"}</dd></div>
<div className="flex gap-2"><dt className="w-32 text-brand-text/60">Email ověřen</dt><dd>{user?.email_verified ? "Ano" : "Ne"}</dd></div>
</dl>
</Card>
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg flex items-center gap-2">
<FaCheckCircle />
<span>Změny byly úspěšně uloženy!</span>
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
<strong>Chyba:</strong> {error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaUser className="text-gray-500" />
Jméno
</label>
<input
type="text"
name="first_name"
value={formData.first_name}
onChange={handleChange}
placeholder="Jan"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
maxLength={150}
/>
<Card>
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
{success && (
<div className="flex items-center gap-2 rounded-xl border border-brand-accent/40 bg-brand-accent/10 px-4 py-3 text-sm text-brand-accent">
<FiCheckCircle />
Změny byly úspěšně uloženy.
</div>
)}
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaUser className="text-gray-500" />
Příjmení
</label>
<input
type="text"
name="last_name"
value={formData.last_name}
onChange={handleChange}
placeholder="Novák"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
maxLength={150}
/>
</div>
</div>
<FormErrorBanner message={rootError} />
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaPhone className="text-gray-500" />
Telefon
</label>
<input
type="tel"
name="phone_number"
value={formData.phone_number}
onChange={handleChange}
placeholder="+420123456789"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
pattern="^\+?\d{9,15}$"
maxLength={16}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="Jméno"
icon={<FiUser />}
placeholder="Jan"
maxLength={150}
autoComplete="given-name"
disabled={isSubmitting}
error={errors.first_name?.message}
{...register("first_name")}
/>
<Input
label="Příjmení"
icon={<FiUser />}
placeholder="Novák"
maxLength={150}
autoComplete="family-name"
disabled={isSubmitting}
error={errors.last_name?.message}
{...register("last_name")}
/>
<p className="text-xs text-gray-500 mt-1">Formát: +420123456789</p>
</div>
<div className="border-t pt-6">
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<FaMapMarkerAlt className="text-gray-500" />
Adresa
<Input
type="tel"
label="Telefon"
icon={<FiPhone />}
placeholder="+420123456789"
pattern="^\+?\d{9,15}$"
maxLength={16}
autoComplete="tel"
disabled={isSubmitting}
error={errors.phone_number?.message}
{...register("phone_number")}
/>
<div className="space-y-4 border-t border-brand-lines/15 pt-5">
<h3 className="flex items-center gap-2 font-semibold text-brand-text">
<FiMapPin /> Adresa
</h3>
<div className="space-y-4">
<div>
<label className="block mb-2 font-medium text-gray-700">Město</label>
<input
type="text"
name="city"
value={formData.city}
onChange={handleChange}
placeholder="Praha"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
maxLength={100}
/>
</div>
<div>
<label className="block mb-2 font-medium text-gray-700">Ulice a číslo popisné</label>
<input
type="text"
name="street"
value={formData.street}
onChange={handleChange}
placeholder="Václavské náměstí 1"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
maxLength={200}
/>
</div>
<div>
<label className="block mb-2 font-medium text-gray-700">PSČ</label>
<input
type="text"
name="postal_code"
value={formData.postal_code}
onChange={handleChange}
placeholder="11000"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
pattern="^\d{5}$"
maxLength={5}
/>
<p className="text-xs text-gray-500 mt-1">5 číslic bez mezery</p>
</div>
</div>
<Input
label="Město"
placeholder="Praha"
maxLength={100}
autoComplete="address-level2"
disabled={isSubmitting}
error={errors.city?.message}
{...register("city")}
/>
<Input
label="Ulice a číslo popisné"
placeholder="Václavské náměstí 1"
maxLength={200}
autoComplete="street-address"
disabled={isSubmitting}
error={errors.street?.message}
{...register("street")}
/>
<Input
label="PSČ"
placeholder="11000"
pattern="^\d{5}$"
maxLength={5}
autoComplete="postal-code"
disabled={isSubmitting}
error={errors.postal_code?.message}
{...register("postal_code")}
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin" />
Ukládání...
</>
) : (
"Uložit změny"
)}
</button>
</div>
<Button type="submit" loading={isSubmitting} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner size={16} /> Ukládání...
</>
) : (
"Uložit změny"
)}
</Button>
</form>
</div>
</Card>
</div>
</div>
);
}
}

View File

@@ -1,108 +1,114 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useAuth } from "@/context/AuthContext";
import { useNavigate, Link } from "react-router-dom";
import { FaEnvelope, FaLock, FaSpinner } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { FiAtSign, FiLock } from "react-icons/fi";
import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input";
import Button from "@/components/ui/Button";
import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
interface LoginForm {
username: string;
password: string;
}
export default function LoginPage() {
const { login, isLoading } = useAuth();
const { t } = useTranslation("auth");
const { login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!username.trim() || !password.trim()) {
setError("Vyplňte prosím všechna pole");
return;
}
const form = useForm<LoginForm>({
defaultValues: { username: "", password: "" },
});
const { register, handleSubmit, formState, clearErrors } = form;
const { errors, isSubmitting } = formState;
const [rootError, setRootError] = useState<string | undefined>();
async function onSubmit(values: LoginForm) {
setRootError(undefined);
clearErrors();
try {
await login({ username, password });
navigate("/");
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message;
setError(errorMessage);
await login(values);
navigate("/social/feed");
} catch (err) {
setRootError(applyServerErrors(form, err) ?? t("login.errors.generic"));
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
<h1 className="text-3xl font-bold text-center mb-2 text-gray-800">Přihlášení</h1>
<p className="text-center text-gray-600 mb-8">Vítejte zpět na vontor.cz</p>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
<strong>Chyba:</strong> {error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaEnvelope className="text-gray-500" />
Email nebo uživatelské jméno
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="email@example.com nebo username"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
required
/>
</div>
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaLock className="text-gray-500" />
Heslo
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
disabled={isLoading}
required
/>
</div>
// Client-side validation failed — at least clear any stale root banner
// so the user isn't shown a server message that no longer applies.
function onInvalid() {
setRootError(undefined);
}
<div className="flex items-center justify-between text-sm">
<Link to="/password-reset" className="text-blue-600 hover:text-blue-800 hover:underline">
Zapomenuté heslo?
const disabled = isSubmitting;
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md" padded={false}>
<div className="px-8 pt-8 pb-2">
<h1 className="text-3xl font-bold text-rainbow">{t("login.title")}</h1>
<p className="mt-1 text-sm text-brand-text/70">{t("login.subtitle")}</p>
</div>
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5 p-8" noValidate>
<FormErrorBanner message={rootError} />
<Input
label={t("login.usernameLabel")}
placeholder={t("login.usernamePlaceholder")}
icon={<FiAtSign />}
autoComplete="username"
error={errors.username?.message}
disabled={disabled}
{...register("username", { required: t("login.errors.missing") })}
/>
<Input
type="password"
label={t("login.passwordLabel")}
placeholder={t("login.passwordPlaceholder")}
icon={<FiLock />}
autoComplete="current-password"
error={errors.password?.message}
disabled={disabled}
{...register("password", { required: t("login.errors.missing") })}
/>
<div className="text-right text-xs">
<Link
to="/social/password-reset"
className="text-brand-lines hover:text-brand-accent"
>
{t("login.forgot")}
</Link>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
>
{isLoading ? (
<Button type="submit" fullWidth loading={disabled} disabled={disabled}>
{disabled ? (
<>
<FaSpinner className="animate-spin" />
Přihlašování...
<Spinner size={16} /> {t("login.submitting")}
</>
) : (
"Přihlásit se"
t("login.submit")
)}
</button>
</form>
</Button>
<div className="mt-6 text-center text-gray-600">
Ještě nemáte účet?{" "}
<Link to="/register" className="text-blue-600 hover:text-blue-800 font-semibold hover:underline">
Zaregistrujte se
</Link>
</div>
</div>
<p className="text-center text-sm text-brand-text/70">
{t("login.noAccount")}{" "}
<Link
to="/social/register"
className="font-semibold text-brand-accent hover:underline"
>
{t("login.registerCta")}
</Link>
</p>
</form>
</Card>
</div>
);
}
}

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useAuth } from "@/context/AuthContext";
import { useNavigate } from "react-router-dom";
import { FaSpinner } from "react-icons/fa";
import Spinner from "@/components/ui/Spinner";
export default function LogoutPage() {
const { logout } = useAuth();
@@ -12,15 +12,12 @@ export default function LogoutPage() {
await logout();
navigate("/");
}
performLogout();
void performLogout();
}, [logout, navigate]);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<div className="bg-white rounded-lg shadow-xl p-8 text-center">
<FaSpinner className="text-blue-500 text-5xl mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-800">Odhlašování...</h1>
</div>
<div className="min-h-screen flex items-center justify-center">
<Spinner size={36} />
</div>
);
}
}

View File

@@ -0,0 +1,26 @@
import { Link } from "react-router-dom";
import { FiArrowLeft, FiMail } from "react-icons/fi";
import Card from "@/components/ui/Card";
import EmptyState from "@/components/ui/EmptyState";
export default function PasswordResetPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<EmptyState
icon={<FiMail />}
title="Obnova hesla"
message="Připravujeme."
action={
<Link
to="/social/login"
className="inline-flex items-center gap-1 text-sm text-brand-accent hover:underline"
>
<FiArrowLeft size={14} /> Zpět na přihlášení
</Link>
}
/>
</Card>
</div>
);
}

View File

@@ -1,186 +1,265 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate, Link } from "react-router-dom";
import { FaUser, FaEnvelope, FaLock, FaSpinner, FaCheckCircle } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import {
FiMail,
FiLock,
FiCheckCircle,
FiUser,
FiPhone,
FiMapPin,
} from "react-icons/fi";
import { apiAccountRegisterCreate } from "@/api/generated/public/account";
import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input";
import Button from "@/components/ui/Button";
import Checkbox from "@/components/ui/Checkbox";
import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
interface RegisterForm {
username: string;
email: string;
password: string;
password2: string;
gdpr: boolean;
first_name: string;
last_name: string;
phone_number: string;
city: string;
street: string;
postal_code: string;
}
export default function RegisterPage() {
const { t } = useTranslation("auth");
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
password2: "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [rootError, setRootError] = useState<string | undefined>();
const [success, setSuccess] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
const form = useForm<RegisterForm>({
defaultValues: {
username: "",
email: "",
password: "",
password2: "",
gdpr: false,
first_name: "",
last_name: "",
phone_number: "",
city: "",
street: "",
postal_code: "",
},
});
const { register, handleSubmit, formState, getValues, clearErrors } = form;
const { errors, isSubmitting } = formState;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
// Validation
if (!formData.username.trim() || !formData.email.trim() || !formData.password.trim()) {
setError("Vyplňte prosím všechna pole");
return;
}
if (formData.password !== formData.password2) {
setError("Hesla se neshodují");
return;
}
if (formData.password.length < 8) {
setError("Heslo musí mít alespoň 8 znaků");
return;
}
setIsLoading(true);
async function onSubmit(values: RegisterForm) {
setRootError(undefined);
// Wipe any stale server errors before the new request — RHF only
// re-validates fields that have rules, so server errors on optional
// fields (first_name, address, etc.) would otherwise stick forever.
clearErrors();
const { password2: _ignore, ...payload } = values;
void _ignore;
try {
await apiAccountRegisterCreate({
username: formData.username,
email: formData.email,
password: formData.password,
password2: formData.password2,
});
// BE serializer doesn't know about password2; strip before sending.
// Cast to the orval-generated type — fields may be re-typed as optional
// once the schema is regenerated via `npm run api:gen`.
await apiAccountRegisterCreate(
payload as Parameters<typeof apiAccountRegisterCreate>[0],
);
setSuccess(true);
setTimeout(() => navigate("/login"), 2000);
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || "Registrace se nezdařila";
setError(errorMessage);
} finally {
setIsLoading(false);
setTimeout(() => navigate("/social/login"), 1500);
} catch (err) {
setRootError(applyServerErrors(form, err) ?? t("register.errors.generic"));
}
}
function onInvalid() {
// Client validation blocked submit — clear the stale root banner.
setRootError(undefined);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md text-center">
<FaCheckCircle className="text-green-500 text-6xl mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-800 mb-2">Registrace úspěšná!</h1>
<p className="text-gray-600 mb-4">Váš účet byl vytvořen. Přesměrování na přihlášení...</p>
</div>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md text-center" padded>
<FiCheckCircle className="mx-auto text-brand-accent" size={56} />
<h1 className="mt-3 text-2xl font-bold text-brand-text">
{t("register.successTitle")}
</h1>
<p className="mt-2 text-brand-text/70">{t("register.successBody")}</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-100 p-4">
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
<h1 className="text-3xl font-bold text-center mb-2 text-gray-800">Registrace</h1>
<p className="text-center text-gray-600 mb-8">Vytvořte si účet na vontor.cz</p>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
<strong>Chyba:</strong> {error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaUser className="text-gray-500" />
Uživatelské jméno
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="username"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
disabled={isLoading}
required
/>
</div>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md" padded={false}>
<div className="px-8 pt-8 pb-2">
<h1 className="text-3xl font-bold text-rainbow">
{t("register.title")}
</h1>
<p className="mt-1 text-sm text-brand-text/70">
{t("register.subtitle")}
</p>
</div>
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaEnvelope className="text-gray-500" />
Email
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="email@example.com"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
disabled={isLoading}
required
/>
</div>
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaLock className="text-gray-500" />
Heslo
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="••••••••"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
disabled={isLoading}
required
minLength={8}
/>
<p className="text-xs text-gray-500 mt-1">Minimálně 8 znaků</p>
</div>
<form
onSubmit={handleSubmit(onSubmit, onInvalid)}
className="space-y-4 p-8"
noValidate
>
<FormErrorBanner message={rootError} />
<div>
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
<FaLock className="text-gray-500" />
Potvrďte heslo
</label>
<input
type="password"
name="password2"
value={formData.password2}
onChange={handleChange}
placeholder="••••••••"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
disabled={isLoading}
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-semibold hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition flex items-center justify-center gap-2 mt-6"
>
{isLoading ? (
<Input
type="email"
label={t("register.usernameLabel")}
icon={<FiUser />}
autoComplete="username"
disabled={isSubmitting}
error={errors.username?.message}
{...register("username", { required: t("register.errors.usernameRequired") })}
/>
<Input
type="email"
label={t("register.emailLabel")}
icon={<FiMail />}
autoComplete="email"
disabled={isSubmitting}
error={errors.email?.message}
{...register("email", { required: t("register.errors.emailRequired") })}
/>
<Input
type="password"
label={t("register.passwordLabel")}
icon={<FiLock />}
autoComplete="new-password"
disabled={isSubmitting}
error={errors.password?.message}
{...register("password", {
required: t("register.errors.passwordRequired"),
minLength: { value: 8, message: t("register.errors.tooShort") },
})}
/>
<p className="-mt-3 text-xs text-brand-text/60">
{t("register.passwordHint")}
</p>
<Input
type="password"
label={t("register.password2Label")}
icon={<FiLock />}
autoComplete="new-password"
disabled={isSubmitting}
error={errors.password2?.message}
{...register("password2", {
required: t("register.errors.password2Required"),
validate: (v) =>
v === getValues("password") || t("register.errors.mismatch"),
})}
/>
<Checkbox
label={t("register.gdprLabel")}
disabled={isSubmitting}
error={errors.gdpr?.message}
{...register("gdpr", {
validate: (value) => value || t("register.errors.gdprRequired"),
})}
/>
<details className="rounded-xl px-3 py-2">
<summary className="cursor-pointer text-sm text-brand-text/80 select-none">
{t("register.optionalToggle")}
</summary>
<div className="mt-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input
label={t("register.fields.firstName")}
icon={<FiUser />}
autoComplete="given-name"
disabled={isSubmitting}
error={errors.first_name?.message}
{...register("first_name")}
/>
<Input
label={t("register.fields.lastName")}
icon={<FiUser />}
autoComplete="family-name"
disabled={isSubmitting}
error={errors.last_name?.message}
{...register("last_name")}
/>
</div>
<Input
label={t("register.fields.phone")}
icon={<FiPhone />}
placeholder="+420..."
autoComplete="tel"
disabled={isSubmitting}
error={errors.phone_number?.message}
{...register("phone_number")}
/>
<Input
label={t("register.fields.street")}
icon={<FiMapPin />}
autoComplete="street-address"
disabled={isSubmitting}
error={errors.street?.message}
{...register("street")}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label={t("register.fields.city")}
icon={<FiMapPin />}
autoComplete="address-level2"
disabled={isSubmitting}
error={errors.city?.message}
{...register("city")}
/>
<Input
label={t("register.fields.postalCode")}
autoComplete="postal-code"
disabled={isSubmitting}
error={errors.postal_code?.message}
{...register("postal_code")}
/>
</div>
</div>
</details>
<Button type="submit" fullWidth loading={isSubmitting} disabled={isSubmitting}>
{isSubmitting ? (
<>
<FaSpinner className="animate-spin" />
Vytváření účtu...
<Spinner size={16} /> {t("register.submitting")}
</>
) : (
"Zaregistrovat se"
t("register.submit")
)}
</button>
</form>
</Button>
<div className="mt-6 text-center text-gray-600">
Již máte účet?{" "}
<Link to="/login" className="text-purple-600 hover:text-purple-800 font-semibold hover:underline">
Přihlaste se
</Link>
</div>
</div>
<p className="text-center text-sm text-brand-text/70">
{t("register.haveAccount")}{" "}
<Link
to="/social/login"
className="font-semibold text-brand-accent hover:underline"
>
{t("register.loginCta")}
</Link>
</p>
</form>
</Card>
</div>
);
}
}

View File

@@ -0,0 +1,205 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Message as MessageModel } from "@/api/generated/private/models/message";
import { useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat";
import { useInfiniteMessages } from "@/hooks/useInfiniteMessages";
import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket";
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed";
import Message from "@/components/social/chat/Message";
import MessageComposer from "@/components/social/chat/MessageComposer";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
import Avatar from "@/components/ui/Avatar";
export default function ChatRoomPage() {
const { t } = useTranslation("social");
const { chatId: chatIdParam } = useParams<{ chatId: string }>();
const chatId = Number(chatIdParam);
const queryClient = useQueryClient();
const { data: chat } = useApiSocialChatsRetrieve(String(chatId));
const {
messages,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteMessages({ chatId });
const [replyTo, setReplyTo] = useState<MessageModel | null>(null);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement | null>(null);
// Top sentinel triggers loading older messages (scroll-back).
const topSentinelRef = useIntersectionLoader<HTMLDivElement>(
() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
},
{ enabled: hasNextPage && !isLoading },
);
// Append a freshly received message into the cursor cache.
const appendMessage = useCallback(
(msg: MessageModel) => {
queryClient.setQueryData<{
pages: CursorPaginated<MessageModel>[];
pageParams: unknown[];
}>(messagesQueryKey(chatId), (old) => {
if (!old) return old as never;
const [first, ...rest] = old.pages;
if (!first) return old;
if (first.results.some((m) => m.id === msg.id)) return old;
return {
...old,
pages: [{ ...first, results: [msg, ...first.results] }, ...rest],
};
});
},
[queryClient, chatId],
);
const removeMessage = useCallback(
(messageId: number) => {
queryClient.setQueryData<{
pages: CursorPaginated<MessageModel>[];
pageParams: unknown[];
}>(messagesQueryKey(chatId), (old) => {
if (!old) return old as never;
return {
...old,
pages: old.pages.map((p) => ({
...p,
results: p.results.filter((m) => m.id !== messageId),
})),
};
});
},
[queryClient, chatId],
);
const handleSocketEvent = useCallback(
(event: ChatSocketEvent) => {
if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") {
appendMessage({
id: event.message_id,
chat: chatId,
sender: null,
reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null,
content: event.message,
is_edited: false,
edited_at: null,
created_at: new Date(),
updated_at: new Date(),
media_files: [],
reactions: [],
});
} else if (event.type === "delete_chat_message") {
removeMessage(event.message_id);
} else if (event.type === "typing") {
setTypingUsers((prev) =>
event.is_typing
? prev.includes(event.user) ? prev : [...prev, event.user]
: prev.filter((u) => u !== event.user),
);
} else if (event.type === "stop_typing") {
setTypingUsers((prev) => prev.filter((u) => u !== event.user));
}
},
[appendMessage, removeMessage, chatId],
);
const { status, sendMessage, sendReply, sendReaction, sendTyping } = useChatSocket({
chatId: Number.isFinite(chatId) ? chatId : null,
onEvent: handleSocketEvent,
});
// Auto-scroll to bottom when new messages arrive.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages.length]);
function handleSend(text: string, replyToId?: number): boolean {
return replyToId ? sendReply(text, replyToId) : sendMessage(text);
}
if (!Number.isFinite(chatId)) {
return <EmptyState message={t("chat.room.selectChat")} />;
}
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b border-brand-lines/15 px-4 py-3">
<Avatar
name={chat?.name ?? `chat ${chatId}`}
src={chat?.icon ?? undefined}
size={36}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-brand-text">
{chat?.name || `Chat #${chatId}`}
</div>
{status !== "open" && (
<div className="text-xs text-brand-text/60">
{t("chat.room.disconnected")}
</div>
)}
</div>
</header>
<div className="flex-1 overflow-y-auto py-2">
{hasNextPage && (
<div ref={topSentinelRef} className="flex justify-center py-2">
{isFetchingNextPage ? (
<Spinner size={18} />
) : (
<span className="text-xs text-brand-text/50">
{t("chat.room.loadingHistory")}
</span>
)}
</div>
)}
{isLoading && (
<div className="flex justify-center py-6">
<Spinner size={24} />
</div>
)}
{!isLoading && messages.length === 0 && (
<EmptyState message={t("chat.room.noMessages")} />
)}
{messages.map((m) => (
<Message
key={m.id}
message={m}
chat={chat ?? null}
onReply={setReplyTo}
onReact={(msg, emoji) => sendReaction(msg.id, emoji)}
/>
))}
<div ref={bottomRef} />
</div>
{typingUsers.length > 0 && (
<div className="px-4 py-1 text-xs text-brand-text/60">
{typingUsers.length === 1
? t("chat.room.typing", { user: typingUsers[0] })
: t("chat.room.typingMany")}
</div>
)}
<MessageComposer
disabled={status !== "open"}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
onSend={handleSend}
onTyping={sendTyping}
/>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { FiMessageCircle } from "react-icons/fi";
import EmptyState from "@/components/ui/EmptyState";
export default function ChatsIndexPage() {
const { t } = useTranslation("social");
return (
<div className="flex h-full items-center justify-center">
<EmptyState
icon={<FiMessageCircle size={32} />}
message={t("chat.room.selectChat")}
/>
</div>
);
}

View File

@@ -1,28 +1,22 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { FaSpinner } from "react-icons/fa";
import Spinner from "@/components/ui/Spinner";
export default function PrivateRoute() {
const location = useLocation();
const { isAuthenticated, isLoading } = useAuth();
// Zobraz loading během načítání uživatele
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<FaSpinner className="text-blue-500 text-5xl mx-auto mb-4 animate-spin" />
<p className="text-gray-600">Načítání...</p>
</div>
<div className="min-h-screen flex items-center justify-center">
<Spinner size={36} />
</div>
);
}
// Pokud není přihlášen, redirect na login (ulož původní cestu)
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
return <Navigate to="/social/login" replace state={{ from: location }} />;
}
// Uživatel je přihlášen, renderuj chráněný obsah
return <Outlet />;
}

View File

@@ -0,0 +1,21 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import Spinner from "@/components/ui/Spinner";
export default function PublicOnlyRoute() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Spinner size={36} />
</div>
);
}
if (isAuthenticated) {
return <Navigate to="/social/feed" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,105 @@
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
export interface ParsedFormError {
/** Form-level message (non_field_errors, detail, network failure, etc.) */
root?: string;
/** Field-name → first human-readable error message */
fields: Record<string, string>;
}
interface MaybeAxiosError {
response?: { data?: unknown; status?: number };
message?: string;
isAxiosError?: boolean;
}
const ROOT_KEYS = new Set(["non_field_errors", "detail", "error", "errors"]);
function firstString(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
for (const v of value) {
const s = firstString(v);
if (s) return s;
}
}
return undefined;
}
/**
* Parses an axios / fetch error from a DRF backend into a normalized shape.
*
* Handles: per-field `{name: ["msg", ...]}`, per-field `{name: "msg"}`,
* `{detail}`, `{non_field_errors}`, plain string body, and network failures
* (no response).
*/
export function parseDrfErrors(err: unknown): ParsedFormError {
const result: ParsedFormError = { fields: {} };
const e = err as MaybeAxiosError;
// Network or unknown failure — no response at all.
if (!e?.response) {
result.root = e?.message || "Síťová chyba. Zkuste to prosím znovu.";
return result;
}
const data = e.response.data;
// String body: `"Something went wrong"`
if (typeof data === "string") {
result.root = data;
return result;
}
// Object body — DRF normal shape.
if (data && typeof data === "object") {
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
const msg = firstString(value);
if (!msg) continue;
if (ROOT_KEYS.has(key)) {
result.root = result.root ?? msg;
} else {
result.fields[key] = msg;
}
}
}
if (!result.root && Object.keys(result.fields).length === 0) {
result.root = e.message || `Chyba ${e.response.status ?? ""}`.trim();
}
return result;
}
/**
* Applies a parsed DRF error onto a react-hook-form instance:
* - calls `setError` for each field with a matching name
* - returns the root error string (if any) so the caller can render a banner
* - field errors that don't map to a known form field collapse into `root`
* so nothing is silently dropped
*/
export function applyServerErrors<T extends FieldValues>(
form: UseFormReturn<T>,
err: unknown,
): string | undefined {
const parsed = parseDrfErrors(err);
const known = new Set(Object.keys(form.getValues() as object));
const orphan: string[] = [];
for (const [name, message] of Object.entries(parsed.fields)) {
if (known.has(name)) {
form.setError(name as Path<T>, { type: "server", message });
} else {
// Field name from server doesn't exist in this form — surface in banner
// rather than swallow it.
orphan.push(`${name}: ${message}`);
}
}
if (orphan.length) {
return parsed.root
? `${parsed.root}\n${orphan.join("\n")}`
: orphan.join("\n");
}
return parsed.root;
}

View File

@@ -0,0 +1,16 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/cs";
dayjs.extend(relativeTime);
dayjs.locale("cs");
export function formatRelative(date: string | Date | null | undefined): string {
if (!date) return "";
return dayjs(date).fromNow();
}
export function formatAbsolute(date: string | Date | null | undefined): string {
if (!date) return "";
return dayjs(date).format("DD.MM.YYYY HH:mm");
}

View File

@@ -9,6 +9,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",