Phase 3-4: Goals AI planner, Team tasks, Push notifications, i18n (CZ/HE/RU/UA)

- Goals CRUD API + AI study plan generator + progress reports
- Goals frontend page with progress bars
- Team tasks: assign, transfer, collaborate endpoints
- Push notifications: web-push, VAPID, subscribe/send
- i18n: 4 languages (cs, he, ru, ua) translation files
- notifications.js + goals.js routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude CLI Agent
2026-03-29 13:12:19 +00:00
parent eac9e72404
commit fea4d38ce8
19 changed files with 1176 additions and 112 deletions

View File

@@ -0,0 +1,482 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import {
getGoals,
getGoal,
createGoal,
updateGoal,
deleteGoal,
generateGoalPlan,
getGoalReport,
getGroups,
Goal,
GoalPlanResult,
GoalReport,
Group,
} from "@/lib/api";
export default function GoalsPage() {
const { token } = useAuth();
const router = useRouter();
const [goals, setGoals] = useState<Goal[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [selectedGoal, setSelectedGoal] = useState<(Goal & { tasks?: unknown[] }) | null>(null);
const [planResult, setPlanResult] = useState<GoalPlanResult | null>(null);
const [report, setReport] = useState<GoalReport | null>(null);
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state
const [formTitle, setFormTitle] = useState("");
const [formDate, setFormDate] = useState("");
const [formGroup, setFormGroup] = useState("");
const loadData = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [goalsRes, groupsRes] = await Promise.all([
getGoals(token),
getGroups(token),
]);
setGoals(goalsRes.data || []);
setGroups(groupsRes.data || []);
} catch (err) {
console.error("Chyba pri nacitani:", err);
setError("Nepodarilo se nacist data");
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
if (!token) {
router.replace("/login");
return;
}
loadData();
}, [token, router, loadData]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!token || !formTitle.trim()) return;
try {
await createGoal(token, {
title: formTitle.trim(),
target_date: formDate || null,
group_id: formGroup || null,
});
setFormTitle("");
setFormDate("");
setFormGroup("");
setShowForm(false);
loadData();
} catch (err) {
console.error("Chyba pri vytvareni:", err);
setError("Nepodarilo se vytvorit cil");
}
}
async function handleSelectGoal(goal: Goal) {
if (!token) return;
try {
const res = await getGoal(token, goal.id);
setSelectedGoal(res.data);
setPlanResult(null);
setReport(null);
} catch (err) {
console.error("Chyba pri nacitani cile:", err);
}
}
async function handleGeneratePlan(goalId: string) {
if (!token) return;
setAiLoading("plan");
setError(null);
try {
const res = await generateGoalPlan(token, goalId);
setPlanResult(res.data);
// Reload goal to get updated plan
const updated = await getGoal(token, goalId);
setSelectedGoal(updated.data);
loadData();
} catch (err) {
console.error("Chyba pri generovani planu:", err);
setError("Nepodarilo se vygenerovat plan. Zkuste to znovu.");
} finally {
setAiLoading(null);
}
}
async function handleGetReport(goalId: string) {
if (!token) return;
setAiLoading("report");
setError(null);
try {
const res = await getGoalReport(token, goalId);
setReport(res.data);
} catch (err) {
console.error("Chyba pri ziskavani reportu:", err);
setError("Nepodarilo se ziskat report. Zkuste to znovu.");
} finally {
setAiLoading(null);
}
}
async function handleUpdateProgress(goalId: string, pct: number) {
if (!token) return;
try {
await updateGoal(token, goalId, { progress_pct: pct } as Partial<Goal>);
loadData();
if (selectedGoal && selectedGoal.id === goalId) {
setSelectedGoal({ ...selectedGoal, progress_pct: pct });
}
} catch (err) {
console.error("Chyba pri aktualizaci:", err);
}
}
async function handleDelete(goalId: string) {
if (!token) return;
if (!confirm("Opravdu chcete smazat tento cil?")) return;
try {
await deleteGoal(token, goalId);
setSelectedGoal(null);
loadData();
} catch (err) {
console.error("Chyba pri mazani:", err);
}
}
function formatDate(d: string | null) {
if (!d) return "Bez terminu";
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" });
}
function progressColor(pct: number) {
if (pct >= 80) return "bg-green-500";
if (pct >= 50) return "bg-blue-500";
if (pct >= 20) return "bg-yellow-500";
return "bg-gray-400";
}
if (!token) return null;
return (
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold dark:text-white">Cile</h1>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors min-h-[44px]"
>
{showForm ? "Zrusit" : "+ Novy cil"}
</button>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">Zavrrit</button>
</div>
)}
{/* Create form */}
{showForm && (
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nazev cile</label>
<input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="Napr. Naucit se TypeScript"
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-h-[44px]"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Termin</label>
<input
type="date"
value={formDate}
onChange={(e) => setFormDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 min-h-[44px]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Skupina</label>
<select
value={formGroup}
onChange={(e) => setFormGroup(e.target.value)}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 min-h-[44px]"
>
<option value="">-- Bez skupiny --</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.icon ? g.icon + " " : ""}{g.name}</option>
))}
</select>
</div>
</div>
<button
type="submit"
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors min-h-[44px]"
>
Vytvorit cil
</button>
</form>
)}
{/* Goals list */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : goals.length === 0 ? (
<div className="text-center py-16">
<div className="text-5xl mb-4 opacity-50">&#127919;</div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Zadne cile</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Vytvorte svuj prvni cil</p>
</div>
) : (
<div className="space-y-2">
{goals.map((goal) => (
<button
key={goal.id}
onClick={() => handleSelectGoal(goal)}
className={`w-full text-left bg-white dark:bg-gray-900 rounded-xl border p-4 transition-all hover:shadow-md ${
selectedGoal?.id === goal.id
? "border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800"
: "border-gray-200 dark:border-gray-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{goal.group_icon && <span className="text-lg">{goal.group_icon}</span>}
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{goal.title}</h3>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDate(goal.target_date)}</span>
{goal.group_name && (
<span className="px-2 py-0.5 rounded-full text-xs" style={{ backgroundColor: (goal.group_color || "#6b7280") + "20", color: goal.group_color || "#6b7280" }}>
{goal.group_name}
</span>
)}
</div>
</div>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 whitespace-nowrap">
{goal.progress_pct}%
</span>
</div>
{/* Progress bar */}
<div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`}
style={{ width: `${goal.progress_pct}%` }}
/>
</div>
</button>
))}
</div>
)}
{/* Goal detail panel */}
{selectedGoal && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-bold dark:text-white">{selectedGoal.title}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{formatDate(selectedGoal.target_date)} | Progres: {selectedGoal.progress_pct}%
</p>
</div>
<button
onClick={() => setSelectedGoal(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1 min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress slider */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Progres: {selectedGoal.progress_pct}%
</label>
<input
type="range"
min="0"
max="100"
value={selectedGoal.progress_pct}
onChange={(e) => handleUpdateProgress(selectedGoal.id, parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* AI Action buttons */}
<div className="flex gap-2">
<button
onClick={() => handleGeneratePlan(selectedGoal.id)}
disabled={aiLoading === "plan"}
className="flex-1 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 min-h-[44px]"
>
{aiLoading === "plan" ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Generuji plan...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Generovat plan (AI)
</>
)}
</button>
<button
onClick={() => handleGetReport(selectedGoal.id)}
disabled={aiLoading === "report"}
className="flex-1 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 min-h-[44px]"
>
{aiLoading === "report" ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Generuji report...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Report (AI)
</>
)}
</button>
</div>
{/* Plan result */}
{planResult && (
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-3">
<h3 className="font-semibold text-purple-800 dark:text-purple-300 mb-2">
Vygenerovany plan ({planResult.tasks_created} ukolu vytvoreno)
</h3>
{planResult.plan.weeks ? (
<div className="space-y-2">
{(planResult.plan.weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg p-2">
<p className="text-sm font-medium text-purple-700 dark:text-purple-300">
Tyden {week.week_number}: {week.focus}
</p>
<ul className="mt-1 space-y-0.5">
{(week.tasks || []).map((t, j) => (
<li key={j} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
<span className="w-1 h-1 bg-purple-400 rounded-full flex-shrink-0" />
{t.title}
{t.duration_hours && <span className="text-gray-400 ml-1">({t.duration_hours}h)</span>}
{t.day_of_week && <span className="text-gray-400 ml-1">[{t.day_of_week}]</span>}
</li>
))}
</ul>
</div>
))}
</div>
) : (
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap overflow-auto max-h-64">
{JSON.stringify(planResult.plan, null, 2)}
</pre>
)}
</div>
)}
{/* AI Report */}
{report && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-green-800 dark:text-green-300">AI Report</h3>
<span className="text-sm text-green-600 dark:text-green-400">
{report.stats.done}/{report.stats.total} splneno ({report.stats.pct}%)
</span>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{report.report}</p>
</div>
)}
{/* Existing plan */}
{selectedGoal.plan && Object.keys(selectedGoal.plan).length > 0 && !planResult && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">Ulozeny plan</h3>
{(selectedGoal.plan as Record<string, unknown>).weeks ? (
<div className="space-y-2">
{((selectedGoal.plan as Record<string, unknown>).weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (
<div key={i} className="bg-white dark:bg-gray-900 rounded-lg p-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Tyden {week.week_number}: {week.focus}
</p>
<ul className="mt-1 space-y-0.5">
{(week.tasks || []).map((t, j) => (
<li key={j} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
<span className="w-1 h-1 bg-gray-400 rounded-full flex-shrink-0" />
{t.title}
</li>
))}
</ul>
</div>
))}
</div>
) : (
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap overflow-auto max-h-40">
{JSON.stringify(selectedGoal.plan, null, 2)}
</pre>
)}
</div>
)}
{/* Related tasks */}
{selectedGoal.tasks && selectedGoal.tasks.length > 0 && (
<div>
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
Souvisejici ukoly ({selectedGoal.tasks.length})
</h3>
<div className="space-y-1 max-h-48 overflow-y-auto">
{(selectedGoal.tasks as Array<{ id: string; title: string; status: string }>).map((task) => (
<div
key={task.id}
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
task.status === "done" || task.status === "completed" ? "bg-green-500" :
task.status === "in_progress" ? "bg-blue-500" :
"bg-gray-400"
}`} />
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">{task.title}</span>
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">{task.status}</span>
</div>
))}
</div>
</div>
)}
{/* Delete button */}
<button
onClick={() => handleDelete(selectedGoal.id)}
className="w-full py-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-sm font-medium transition-colors min-h-[44px]"
>
Smazat cil
</button>
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import ThemeProvider from "@/components/ThemeProvider";
import AuthProvider from "@/components/AuthProvider";
import Header from "@/components/Header";
import BottomNav from "@/components/BottomNav";
import { I18nProvider } from "@/lib/i18n";
export const metadata: Metadata = {
title: "Task Team",
@@ -35,15 +36,17 @@ export default function RootLayout({
return (
<html lang="cs" suppressHydrationWarning>
<body className="antialiased min-h-screen">
<ThemeProvider>
<AuthProvider>
<Header />
<main className="w-full max-w-4xl mx-auto py-4 sm:px-4">
{children}
</main>
<BottomNav />
</AuthProvider>
</ThemeProvider>
<I18nProvider>
<ThemeProvider>
<AuthProvider>
<Header />
<main className="w-full max-w-4xl mx-auto py-4 sm:px-4">
{children}
</main>
<BottomNav />
</AuthProvider>
</ThemeProvider>
</I18nProvider>
</body>
</html>
);

View File

@@ -5,18 +5,20 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { login } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { setAuth } = useAuth();
const { t } = useTranslation();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim()) {
setError("Zadejte email");
setError(t("auth.email"));
return;
}
setLoading(true);
@@ -26,7 +28,7 @@ export default function LoginPage() {
setAuth(result.data.token, result.data.user);
router.push("/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "Chyba prihlaseni");
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setLoading(false);
}
@@ -36,7 +38,7 @@ export default function LoginPage() {
<div className="min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-sm">
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6 shadow-sm">
<h1 className="text-2xl font-bold text-center mb-6">Prihlaseni</h1>
<h1 className="text-2xl font-bold text-center mb-6">{t("auth.login")}</h1>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg text-sm mb-4">
@@ -46,7 +48,7 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<label className="block text-sm font-medium mb-1">{t("auth.email")}</label>
<input
type="email"
value={email}
@@ -63,14 +65,14 @@ export default function LoginPage() {
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? "Prihlasuji..." : "Prihlasit se"}
{loading ? t("common.loading") : t("auth.submit")}
</button>
</form>
<p className="text-center text-sm text-muted mt-4">
Nemate ucet?{" "}
{t("auth.noAccount")}{" "}
<Link href="/register" className="text-blue-600 hover:underline">
Registrovat se
{t("auth.registerBtn")}
</Link>
</p>
</div>

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { register } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
export default function RegisterPage() {
const [email, setEmail] = useState("");
@@ -13,12 +14,13 @@ export default function RegisterPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { setAuth } = useAuth();
const { t } = useTranslation();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim() || !name.trim()) {
setError("Email a jmeno jsou povinne");
setError(t("common.error"));
return;
}
setLoading(true);
@@ -32,7 +34,7 @@ export default function RegisterPage() {
setAuth(result.data.token, result.data.user);
router.push("/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "Chyba registrace");
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setLoading(false);
}
@@ -42,7 +44,7 @@ export default function RegisterPage() {
<div className="min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-sm">
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6 shadow-sm">
<h1 className="text-2xl font-bold text-center mb-6">Registrace</h1>
<h1 className="text-2xl font-bold text-center mb-6">{t("auth.register")}</h1>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg text-sm mb-4">
@@ -52,31 +54,29 @@ export default function RegisterPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Jmeno *</label>
<label className="block text-sm font-medium mb-1">{t("auth.name")} *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
placeholder="Vase jmeno"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email *</label>
<label className="block text-sm font-medium mb-1">{t("auth.email")} *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
placeholder="vas@email.cz"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Telefon</label>
<label className="block text-sm font-medium mb-1">{t("auth.phone")}</label>
<input
type="tel"
value={phone}
@@ -91,14 +91,14 @@ export default function RegisterPage() {
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? "Registruji..." : "Registrovat se"}
{loading ? t("common.loading") : t("auth.registerBtn")}
</button>
</form>
<p className="text-center text-sm text-muted mt-4">
Jiz mate ucet?{" "}
{t("auth.hasAccount")}{" "}
<Link href="/login" className="text-blue-600 hover:underline">
Prihlasit se
{t("auth.submit")}
</Link>
</p>
</div>

View File

@@ -4,19 +4,14 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { useTheme } from "@/components/ThemeProvider";
const LANGUAGES = [
{ code: "cs", label: "Čeština", flag: "🇨🇿" },
{ code: "he", label: "עברית", flag: "🇮🇱" },
{ code: "ru", label: "Русский", flag: "🇷🇺" },
{ code: "ua", label: "Українська", flag: "🇺🇦" },
];
import { useTranslation, LOCALES } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
export default function SettingsPage() {
const { token, user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t, locale, setLocale } = useTranslation();
const router = useRouter();
const [language, setLanguage] = useState("cs");
const [notifications, setNotifications] = useState({
push: true,
email: false,
@@ -31,8 +26,6 @@ export default function SettingsPage() {
}
// Load saved preferences
if (typeof window !== "undefined") {
const savedLang = localStorage.getItem("taskteam_language");
if (savedLang) setLanguage(savedLang);
const savedNotifs = localStorage.getItem("taskteam_notifications");
if (savedNotifs) {
try {
@@ -46,7 +39,6 @@ export default function SettingsPage() {
function handleSave() {
if (typeof window !== "undefined") {
localStorage.setItem("taskteam_language", language);
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
}
setSaved(true);
@@ -62,17 +54,17 @@ export default function SettingsPage() {
return (
<div className="max-w-lg mx-auto space-y-6 px-4 pb-24 sm:pb-8">
<h1 className="text-xl font-bold">Nastavení</h1>
<h1 className="text-xl font-bold">{t("settings.title")}</h1>
{/* Profile section */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Profil</h2>
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.profile")}</h2>
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xl font-bold">
{(user?.name || user?.email || "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-semibold text-lg">{user?.name || "Uživatel"}</p>
<p className="font-semibold text-lg">{user?.name || t("settings.user")}</p>
<p className="text-sm text-muted truncate">{user?.email}</p>
</div>
</div>
@@ -80,7 +72,7 @@ export default function SettingsPage() {
{/* Appearance */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Vzhled</h2>
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.appearance")}</h2>
{/* Theme toggle */}
<div className="flex items-center justify-between py-3">
@@ -95,7 +87,7 @@ export default function SettingsPage() {
</svg>
)}
<span className="text-sm font-medium">
{theme === "dark" ? "Tmavý režim" : "Světlý režim"}
{theme === "dark" ? t("settings.dark") : t("settings.light")}
</span>
</div>
<button
@@ -103,7 +95,7 @@ export default function SettingsPage() {
className={`relative w-12 h-7 rounded-full transition-colors ${
theme === "dark" ? "bg-blue-600" : "bg-gray-300"
}`}
aria-label="Přepnout téma"
aria-label={t("common.toggleTheme")}
>
<div
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
@@ -116,14 +108,14 @@ export default function SettingsPage() {
{/* Language */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Jazyk</h2>
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.language")}</h2>
<div className="grid grid-cols-2 gap-2">
{LANGUAGES.map((lang) => (
{LOCALES.map((lang) => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code)}
onClick={() => setLocale(lang.code as Locale)}
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium transition-all ${
language === lang.code
locale === lang.code
? "bg-blue-600 text-white shadow-md"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
@@ -137,13 +129,13 @@ export default function SettingsPage() {
{/* Notifications */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Oznámení</h2>
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.notifications")}</h2>
<div className="space-y-1">
{[
{ key: "push" as const, label: "Push oznámení" },
{ key: "email" as const, label: "E-mailová oznámení" },
{ key: "taskReminders" as const, label: "Připomenutí úkolů" },
{ key: "dailySummary" as const, label: "Denní souhrn" },
{ key: "push" as const, label: t("settings.push") },
{ key: "email" as const, label: t("settings.email") },
{ key: "taskReminders" as const, label: t("settings.taskReminders") },
{ key: "dailySummary" as const, label: t("settings.dailySummary") },
].map((item) => (
<div key={item.key} className="flex items-center justify-between py-3">
<span className="text-sm font-medium">{item.label}</span>
@@ -157,7 +149,7 @@ export default function SettingsPage() {
className={`relative w-12 h-7 rounded-full transition-colors ${
notifications[item.key] ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
aria-label={`Přepnout ${item.label}`}
aria-label={item.label}
>
<div
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
@@ -179,7 +171,7 @@ export default function SettingsPage() {
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{saved ? "Uloženo!" : "Uložit nastavení"}
{saved ? t("settings.saved") : t("settings.save")}
</button>
{/* Logout */}
@@ -187,7 +179,7 @@ export default function SettingsPage() {
onClick={handleLogout}
className="w-full py-3 rounded-xl font-medium border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Odhlásit se
{t("auth.logout")}
</button>
{/* App info */}