-
-
- nothing now
-
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/layouts/social/SocialLayout.tsx b/frontend/src/layouts/social/SocialLayout.tsx
new file mode 100644
index 0000000..9509038
--- /dev/null
+++ b/frontend/src/layouts/social/SocialLayout.tsx
@@ -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:
, labelKey: "nav.feed" },
+ { to: "/social/chats", icon:
, labelKey: "nav.chats" },
+ { to: "/social/hubs", icon:
, labelKey: "nav.hubs" },
+ {
+ to: userId ? `/social/profile/${userId}` : "/social/profile",
+ icon:
,
+ labelKey: "nav.profile",
+ },
+ ];
+}
+
+export default function SocialLayout() {
+ const { t } = useTranslation("social");
+ const { user } = useAuth();
+ const items = buildItems(user?.id);
+
+ return (
+
+
+ {/* Left rail */}
+
+
+ {/* Main column */}
+
+
+
+
+ {/* Right rail (placeholder; hidden on small screens) */}
+
+
+
+ );
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 635bbde..a5e1fd6 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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'
diff --git a/frontend/src/pages/social/FeedPage.tsx b/frontend/src/pages/social/FeedPage.tsx
new file mode 100644
index 0000000..ff57bc2
--- /dev/null
+++ b/frontend/src/pages/social/FeedPage.tsx
@@ -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
(
+ () => {
+ if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
+ },
+ { enabled: hasNextPage && !isLoading },
+ );
+
+ return (
+
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {isError && !isLoading && (
+
void refetch()}
+ className="text-brand-accent hover:underline"
+ >
+ {t("common:retry", { defaultValue: "Zkusit znovu" })}
+
+ }
+ />
+ )}
+
+ {!isLoading && !isError && posts.length === 0 && (
+ } message={t("feed.empty")} />
+ )}
+
+
+ {posts.map((p) => (
+
navigate(`/social/post/${p.id}`)}
+ />
+ ))}
+
+
+ {hasNextPage && (
+
+ {isFetchingNextPage ? : t("feed.loadingMore")}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/social/HubPage.tsx b/frontend/src/pages/social/HubPage.tsx
new file mode 100644
index 0000000..8173abe
--- /dev/null
+++ b/frontend/src/pages/social/HubPage.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (!hub) {
+ return ;
+ }
+
+ return (
+
+
+
+ {postsLoading && (
+
+
+
+ )}
+
+ {!postsLoading && posts.length === 0 &&
}
+
+ {posts.map((p) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/social/HubsPage.tsx b/frontend/src/pages/social/HubsPage.tsx
new file mode 100644
index 0000000..1296824
--- /dev/null
+++ b/frontend/src/pages/social/HubsPage.tsx
@@ -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 (
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!isLoading && hubs.length === 0 && (
+
} message="—" />
+ )}
+
+
+ {hubs.map((hub) => (
+ -
+
+
+
+
+ {hub.name}
+
+ {hub.description && (
+
+ {hub.description}
+
+ )}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/social/PostPage.tsx b/frontend/src/pages/social/PostPage.tsx
new file mode 100644
index 0000000..49973c7
--- /dev/null
+++ b/frontend/src/pages/social/PostPage.tsx
@@ -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 && (
+
+ )}
+
+
+
+ >
+ );
+}
+
+export default function PostPage() {
+ const { t } = useTranslation("social");
+ const { id } = useParams<{ id: string }>();
+ const postId = Number(id);
+
+ const composerRef = useRef(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(
+ () => {
+ if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
+ },
+ { enabled: hasNextPage },
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !post) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ {t("post.detail.back")}
+
+
+ {post.reply_to != null && (
+
+ )}
+
+
+
+
+
+
+ {repliesLoading && (
+
+
+
+ )}
+
+ {!repliesLoading && replies.length === 0 && (
+
+ )}
+
+ {replies.map((r) => (
+
+ ))}
+
+ {hasNextPage && (
+
+ {isFetchingNextPage ? : t("feed.loadingMore")}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/social/ProfilePage.tsx b/frontend/src/pages/social/ProfilePage.tsx
new file mode 100644
index 0000000..7146a0e
--- /dev/null
+++ b/frontend/src/pages/social/ProfilePage.tsx
@@ -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 } title="—" />;
+
+ return (
+
+
+
+
+
+
+
+
+ @{user.username}
+
+ {user.email && (
+
{user.email}
+ )}
+ {(user.first_name || user.last_name) && (
+
+ {[user.first_name, user.last_name].filter(Boolean).join(" ")}
+
+ )}
+
+
+
+
+
+ {t("nav.logout")}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/social/UserProfilePage.tsx b/frontend/src/pages/social/UserProfilePage.tsx
new file mode 100644
index 0000000..26e8a59
--- /dev/null
+++ b/frontend/src/pages/social/UserProfilePage.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+ {profileLoading ? "…" : profile ? `@${profile.username}` : t("profile.notFound")}
+
+
+
+ {profileLoading ? (
+
+
+
+ ) : !profile ? (
+
} title={t("profile.notFound")} />
+ ) : (
+ <>
+ {/* Profile card */}
+
+
+
+
+
+ {[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username}
+
+
@{profile.username}
+
+ {(profile as any).city && (
+
{(profile as any).city}
+ )}
+
+ {(profile as any).create_time && (
+
+
+ {t("profile.joined", { date: formatJoined((profile as any).create_time) })}
+
+ )}
+
+
+ {isOwnProfile && (
+
+
+ {t("profile.editProfile")}
+
+
+ {t("nav.logout")}
+
+
+ )}
+
+
+
+ {/* Posts */}
+
+ {postsLoading ? (
+
+
+
+ ) : posts.length === 0 ? (
+
+ ) : (
+ posts.map((p) => (
+
navigate(`/social/post/${p.id}`)}
+ />
+ ))
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/social/account/AccountSettings.tsx b/frontend/src/pages/social/account/AccountSettings.tsx
index d820f9b..8aa44e8 100644
--- a/frontend/src/pages/social/account/AccountSettings.tsx
+++ b/frontend/src/pages/social/account/AccountSettings.tsx
@@ -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();
const [success, setSuccess] = useState(false);
+ const form = useForm({
+ 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 (
-
+
);
}
if (!isAuthenticated) {
- return ;
+ return ;
}
- const handleChange = (e: React.ChangeEvent) => {
- 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[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 (
-
-
-
-
Nastavení účtu
-
Upravte své osobní údaje
+
+
+
+ Nastavení účtu
+ Upravte své osobní údaje
+
- {/* User info (read-only) */}
-
-
Informace o účtu
-
-
Username: {user?.username}
-
Email: {user?.email}
-
Role: {user?.role || "user"}
-
Email ověřen: {user?.email_verified ? "✅ Ano" : "❌ Ne"}
-
-
+
+ Informace o účtu
+
+ - Username
- {user?.username}
+ - Email
- {user?.email}
+ - Role
- {user?.role || "user"}
+ - Email ověřen
- {user?.email_verified ? "Ano" : "Ne"}
+
+
- {success && (
-
-
- Změny byly úspěšně uloženy!
-
- )}
-
- {error && (
-
- Chyba: {error}
-
- )}
-
-
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/social/account/Login.tsx b/frontend/src/pages/social/account/Login.tsx
index a6adedb..58b21c1 100644
--- a/frontend/src/pages/social/account/Login.tsx
+++ b/frontend/src/pages/social/account/Login.tsx
@@ -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
({
+ defaultValues: { username: "", password: "" },
+ });
+ const { register, handleSubmit, formState, clearErrors } = form;
+ const { errors, isSubmitting } = formState;
+ const [rootError, setRootError] = useState();
+ 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 (
-
-
-
Přihlášení
-
Vítejte zpět na vontor.cz
-
- {error && (
-
- Chyba: {error}
-
- )}
-
-