added frontend for social + feed partiali working
This commit is contained in:
10
frontend/.env.example
Normal file
10
frontend/.env.example
Normal 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
|
||||
83
frontend/package-lock.json
generated
83
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
20
frontend/src/api/generated/private/models/authorMinimal.ts
Normal file
20
frontend/src/api/generated/private/models/authorMinimal.ts
Normal 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;
|
||||
}
|
||||
@@ -42,4 +42,6 @@ export interface CustomUser {
|
||||
postal_code?: string | null;
|
||||
readonly gdpr: boolean;
|
||||
is_active?: boolean;
|
||||
/** @nullable */
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import type { Message } from "./message";
|
||||
|
||||
export interface PaginatedMessageList {
|
||||
count: number;
|
||||
/** @nullable */
|
||||
next?: string | null;
|
||||
/** @nullable */
|
||||
|
||||
@@ -42,4 +42,6 @@ export interface PatchedCustomUser {
|
||||
postal_code?: string | null;
|
||||
readonly gdpr?: boolean;
|
||||
is_active?: boolean;
|
||||
/** @nullable */
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
20
frontend/src/api/generated/public/models/authorMinimal.ts
Normal file
20
frontend/src/api/generated/public/models/authorMinimal.ts
Normal 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;
|
||||
}
|
||||
@@ -42,4 +42,6 @@ export interface CustomUser {
|
||||
postal_code?: string | null;
|
||||
readonly gdpr: boolean;
|
||||
is_active?: boolean;
|
||||
/** @nullable */
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import type { Message } from "./message";
|
||||
|
||||
export interface PaginatedMessageList {
|
||||
count: number;
|
||||
/** @nullable */
|
||||
next?: string | null;
|
||||
/** @nullable */
|
||||
|
||||
@@ -42,4 +42,6 @@ export interface PatchedCustomUser {
|
||||
postal_code?: string | null;
|
||||
readonly gdpr?: boolean;
|
||||
is_active?: boolean;
|
||||
/** @nullable */
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
75
frontend/src/api/social/feed.ts
Normal file
75
frontend/src/api/social/feed.ts
Normal 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;
|
||||
23
frontend/src/api/social/ws.ts
Normal file
23
frontend/src/api/social/ws.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -360,6 +360,8 @@
|
||||
}
|
||||
|
||||
.navbar .logo{
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
86
frontend/src/components/social/chat/ChatSidebar.tsx
Normal file
86
frontend/src/components/social/chat/ChatSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/components/social/chat/MessageComposer.tsx
Normal file
112
frontend/src/components/social/chat/MessageComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/social/posts/MediaGallery.tsx
Normal file
46
frontend/src/components/social/posts/MediaGallery.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
136
frontend/src/components/social/posts/PostActions.tsx
Normal file
136
frontend/src/components/social/posts/PostActions.tsx
Normal 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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
frontend/src/components/social/posts/PostComposer.tsx
Normal file
190
frontend/src/components/social/posts/PostComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/social/posts/SharePopup.tsx
Normal file
101
frontend/src/components/social/posts/SharePopup.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/Avatar.tsx
Normal file
35
frontend/src/components/ui/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/ui/Button.tsx
Normal file
65
frontend/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/ui/Card.tsx
Normal file
28
frontend/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/ui/Checkbox.tsx
Normal file
40
frontend/src/components/ui/Checkbox.tsx
Normal 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;
|
||||
28
frontend/src/components/ui/EmptyState.tsx
Normal file
28
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/FormErrorBanner.tsx
Normal file
23
frontend/src/components/ui/FormErrorBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/ui/IconButton.tsx
Normal file
34
frontend/src/components/ui/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/ui/Input.tsx
Normal file
42
frontend/src/components/ui/Input.tsx
Normal 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;
|
||||
15
frontend/src/components/ui/Spinner.tsx
Normal file
15
frontend/src/components/ui/Spinner.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/ui/Textarea.tsx
Normal file
40
frontend/src/components/ui/Textarea.tsx
Normal 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;
|
||||
@@ -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() {
|
||||
|
||||
122
frontend/src/hooks/useChatSocket.ts
Normal file
122
frontend/src/hooks/useChatSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
frontend/src/hooks/useInfiniteMessages.ts
Normal file
39
frontend/src/hooks/useInfiniteMessages.ts
Normal 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 };
|
||||
}
|
||||
59
frontend/src/hooks/useInfinitePosts.ts
Normal file
59
frontend/src/hooks/useInfinitePosts.ts
Normal 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 };
|
||||
}
|
||||
39
frontend/src/hooks/useIntersectionLoader.ts
Normal file
39
frontend/src/hooks/useIntersectionLoader.ts
Normal 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;
|
||||
}
|
||||
60
frontend/src/hooks/usePermissions.ts
Normal file
60
frontend/src/hooks/usePermissions.ts
Normal 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;
|
||||
}
|
||||
24
frontend/src/i18n/index.ts
Normal file
24
frontend/src/i18n/index.ts
Normal 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;
|
||||
54
frontend/src/i18n/locales/cs/auth.json
Normal file
54
frontend/src/i18n/locales/cs/auth.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/src/i18n/locales/cs/common.json
Normal file
16
frontend/src/i18n/locales/cs/common.json
Normal 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"
|
||||
}
|
||||
95
frontend/src/i18n/locales/cs/social.json
Normal file
95
frontend/src/i18n/locales/cs/social.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
97
frontend/src/layouts/social/SocialLayout.tsx
Normal file
97
frontend/src/layouts/social/SocialLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
84
frontend/src/pages/social/FeedPage.tsx
Normal file
84
frontend/src/pages/social/FeedPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/pages/social/HubPage.tsx
Normal file
66
frontend/src/pages/social/HubPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/pages/social/HubsPage.tsx
Normal file
54
frontend/src/pages/social/HubsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/social/PostPage.tsx
Normal file
126
frontend/src/pages/social/PostPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/pages/social/ProfilePage.tsx
Normal file
50
frontend/src/pages/social/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/social/UserProfilePage.tsx
Normal file
126
frontend/src/pages/social/UserProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
26
frontend/src/pages/social/account/PasswordResetPage.tsx
Normal file
26
frontend/src/pages/social/account/PasswordResetPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
205
frontend/src/pages/social/chat/ChatRoomPage.tsx
Normal file
205
frontend/src/pages/social/chat/ChatRoomPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/pages/social/chat/ChatsPage.tsx
Normal file
15
frontend/src/pages/social/chat/ChatsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
21
frontend/src/routes/PublicOnlyRoute.tsx
Normal file
21
frontend/src/routes/PublicOnlyRoute.tsx
Normal 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 />;
|
||||
}
|
||||
105
frontend/src/utils/formErrors.ts
Normal file
105
frontend/src/utils/formErrors.ts
Normal 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;
|
||||
}
|
||||
16
frontend/src/utils/relativeTime.ts
Normal file
16
frontend/src/utils/relativeTime.ts
Normal 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");
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user