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

89
api/package-lock.json generated
View File

@@ -21,7 +21,8 @@
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"redis": "^5.11.0",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.1.14"
@@ -435,6 +436,15 @@
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -590,6 +600,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -978,6 +994,15 @@
"node": ">=18.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -998,6 +1023,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1145,6 +1183,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -1236,6 +1295,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -1905,6 +1973,25 @@
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -25,7 +25,8 @@
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"redis": "^5.11.0",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.1.14"

View File

@@ -48,6 +48,8 @@ app.register(require("./routes/connectors/odoo"), { prefix: "/api/v1" });
app.register(require("./routes/connectors/moodle"), { prefix: "/api/v1" });
app.register(require("./routes/connectors/pohoda"), { prefix: "/api/v1" });
app.register(require("./routes/chat"), { prefix: "/api/v1" });
app.register(require("./routes/notifications"), { prefix: "/api/v1" });
app.register(require("./routes/goals"), { prefix: "/api/v1" });
// Graceful shutdown
const shutdown = async (signal) => {

155
api/src/routes/goals.js Normal file
View File

@@ -0,0 +1,155 @@
// Task Team — Goals API + AI Planner — 2026-03-29
const Anthropic = require("@anthropic-ai/sdk");
async function goalRoutes(app) {
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// List goals
app.get("/goals", async (req) => {
const { rows } = await app.db.query(
"SELECT g.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon FROM goals g LEFT JOIN task_groups tg ON g.group_id = tg.id ORDER BY g.target_date ASC NULLS LAST"
);
return { data: rows };
});
// Get goal with related tasks
app.get("/goals/:id", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const { rows: tasks } = await app.db.query(
"SELECT * FROM tasks WHERE external_id LIKE $1 ORDER BY scheduled_at ASC",
["goal:" + req.params.id + "%"]
);
return { data: { ...rows[0], tasks } };
});
// Create goal
app.post("/goals", async (req) => {
const { title, target_date, group_id, user_id, plan } = req.body;
const { rows } = await app.db.query(
"INSERT INTO goals (title, target_date, group_id, user_id, plan) VALUES ($1, $2, $3, $4, $5) RETURNING *",
[title, target_date || null, group_id || null, user_id || null, JSON.stringify(plan || {})]
);
return { data: rows[0] };
});
// Update goal progress
app.put("/goals/:id", async (req) => {
const { title, target_date, progress_pct, plan } = req.body;
const { rows } = await app.db.query(
"UPDATE goals SET title=COALESCE($1,title), target_date=COALESCE($2,target_date), progress_pct=COALESCE($3,progress_pct), plan=COALESCE($4,plan), updated_at=NOW() WHERE id=$5 RETURNING *",
[title, target_date, progress_pct, plan ? JSON.stringify(plan) : null, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
return { data: rows[0] };
});
// Delete goal
app.delete("/goals/:id", async (req) => {
const { rowCount } = await app.db.query("DELETE FROM goals WHERE id = $1", [req.params.id]);
if (!rowCount) throw { statusCode: 404, message: "Goal not found" };
return { status: "deleted" };
});
// AI: Generate study plan for a goal
app.post("/goals/:id/plan", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const goal = rows[0];
const { rows: groups } = await app.db.query(
"SELECT name, time_zones FROM task_groups WHERE id = $1", [goal.group_id]
);
const groupInfo = groups[0] || {};
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: "Jsi planovaci AI asistent. Generujes detailni studijni/pracovni plany v JSON formatu. Odpovidej v cestine.",
messages: [{
role: "user",
content: "Vytvor tydenni plan pro tento cil:\nCil: " + goal.title +
"\nTermin: " + (goal.target_date || "bez terminu") +
"\nAktualni progres: " + goal.progress_pct + "%" +
"\nSkupina: " + (groupInfo.name || "obecna") +
"\nCasove zony skupiny: " + JSON.stringify(groupInfo.time_zones || []) +
"\n\nVrat JSON s polem \"weeks\" kde kazdy tyden ma \"week_number\", \"focus\", \"tasks\" (pole s title, description, duration_hours, day_of_week).\nZahrn spaced repetition pro opakovani."
}]
});
let plan;
try {
const text = response.content[0].text;
const jsonMatch = text.match(/\{[\s\S]*\}/);
plan = jsonMatch ? JSON.parse(jsonMatch[0]) : { raw: text };
} catch (e) {
plan = { raw: response.content[0].text };
}
// Save plan
await app.db.query(
"UPDATE goals SET plan = $1, updated_at = NOW() WHERE id = $2",
[JSON.stringify(plan), goal.id]
);
// Create tasks from plan
let tasksCreated = 0;
if (plan.weeks) {
for (const week of plan.weeks) {
for (const task of (week.tasks || [])) {
await app.db.query(
"INSERT INTO tasks (title, description, group_id, external_id, external_source, status, priority) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
task.title,
task.description || "",
goal.group_id,
"goal:" + goal.id + ":w" + week.week_number,
"goal",
"pending",
"medium"
]
);
tasksCreated++;
}
}
}
return { data: { plan, tasks_created: tasksCreated } };
});
// AI: Progress report for a goal
app.get("/goals/:id/report", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const { rows: tasks } = await app.db.query(
"SELECT title, status, completed_at FROM tasks WHERE external_id LIKE $1",
["goal:" + req.params.id + "%"]
);
const done = tasks.filter(t => t.status === "completed" || t.status === "done").length;
const total = tasks.length;
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: "Jsi AI coach. Davas motivacni zpetnou vazbu k plneni cilu. Odpovidej v cestine, strucne.",
messages: [{
role: "user",
content: "Cil: " + rows[0].title +
"\nSplneno: " + done + "/" + total + " ukolu (" + (total ? Math.round(done / total * 100) : 0) + "%)" +
"\nProgres: " + rows[0].progress_pct + "%" +
"\nDej zpetnou vazbu a doporuceni."
}]
});
return {
data: {
report: response.content[0].text,
stats: { done, total, pct: total ? Math.round(done / total * 100) : 0 }
}
};
});
}
module.exports = goalRoutes;

View File

@@ -0,0 +1,77 @@
// Task Team — Push Notifications — 2026-03-29
const webpush = require("web-push");
async function notificationRoutes(app) {
// Generate VAPID keys if not exist
const vapidKeys = {
publicKey: process.env.VAPID_PUBLIC_KEY || "",
privateKey: process.env.VAPID_PRIVATE_KEY || ""
};
if (!vapidKeys.publicKey) {
const keys = webpush.generateVAPIDKeys();
vapidKeys.publicKey = keys.publicKey;
vapidKeys.privateKey = keys.privateKey;
console.log("VAPID keys generated. Add to env:", JSON.stringify(keys));
}
webpush.setVapidDetails("mailto:admin@hasdo.info", vapidKeys.publicKey, vapidKeys.privateKey);
// Create subscriptions table if not exists
await app.db.query(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
subscription JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Get VAPID public key
app.get("/notifications/vapid-key", async () => {
return { data: { publicKey: vapidKeys.publicKey } };
});
// Subscribe
app.post("/notifications/subscribe", async (req) => {
const { subscription, user_id } = req.body;
await app.db.query(
"INSERT INTO push_subscriptions (user_id, subscription) VALUES ($1, $2)",
[user_id, JSON.stringify(subscription)]
);
return { status: "subscribed" };
});
// Send notification (internal use)
app.post("/notifications/send", async (req) => {
const { user_id, title, body, url } = req.body;
const { rows } = await app.db.query(
"SELECT subscription FROM push_subscriptions WHERE user_id = $1", [user_id]
);
let sent = 0;
for (const row of rows) {
try {
await webpush.sendNotification(
JSON.parse(row.subscription),
JSON.stringify({ title, body, url: url || "/tasks" })
);
sent++;
} catch (e) {
if (e.statusCode === 410) {
await app.db.query("DELETE FROM push_subscriptions WHERE subscription = $1", [row.subscription]);
}
}
}
return { status: "sent", count: sent };
});
// Unsubscribe
app.delete("/notifications/unsubscribe", async (req) => {
const { user_id } = req.body;
await app.db.query("DELETE FROM push_subscriptions WHERE user_id = $1", [user_id]);
return { status: "unsubscribed" };
});
}
module.exports = notificationRoutes;

View File

@@ -153,6 +153,62 @@ async function taskRoutes(app) {
);
return { data: rows[0] };
});
// === Team Collaboration Endpoints ===
// Assign task to user
app.post("/tasks/:id/assign", async (req) => {
const { user_id } = req.body;
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0] };
});
// Transfer task (creates pending transfer)
app.post("/tasks/:id/transfer", async (req) => {
const { to_user_id, message } = req.body;
if (!to_user_id) throw { statusCode: 400, message: "to_user_id is required" };
await app.db.query(
"INSERT INTO task_comments (task_id, content, is_ai) VALUES ($1, $2, true)",
[req.params.id, JSON.stringify({ type: "transfer_request", to: to_user_id, message: message || "", status: "pending" })]
);
return { status: "transfer_requested" };
});
// Accept/reject transfer
app.post("/tasks/:id/transfer/respond", async (req) => {
const { accept, user_id } = req.body;
if (accept) {
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET user_id = $1, updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0], status: "transferred" };
}
return { status: "rejected" };
});
// Collaborate on task
app.post("/tasks/:id/collaborate", async (req) => {
const { user_id } = req.body;
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0] };
});
}
module.exports = taskRoutes;

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 */}

View File

@@ -2,49 +2,60 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
const NAV_ITEMS = [
{
href: "/tasks",
label: "Ukoly",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
},
{
href: "/calendar",
label: "Kalendar",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
},
{
href: "/chat",
label: "Chat",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
},
{
href: "/settings",
label: "Nastaveni",
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
import { useTranslation } from "@/lib/i18n";
export default function BottomNav() {
const pathname = usePathname();
const { t } = useTranslation();
const NAV_ITEMS = [
{
href: "/tasks",
label: t("nav.tasks"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
},
{
href: "/calendar",
label: t("nav.calendar"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
},
{
href: "/goals",
label: t("nav.goals"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
},
{
href: "/chat",
label: t("nav.chat"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
},
{
href: "/settings",
label: t("nav.settings"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
return (
<nav className="bottom-nav fixed bottom-0 left-0 right-0 z-50 bg-white/90 dark:bg-gray-950/90 backdrop-blur-lg border-t border-gray-200 dark:border-gray-800 safe-area-bottom">

View File

@@ -3,12 +3,14 @@
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/auth";
import { useTheme } from "./ThemeProvider";
import { useTranslation } from "@/lib/i18n";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function Header() {
const { user, logout, token } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
const router = useRouter();
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -54,7 +56,7 @@ export default function Header() {
<button
onClick={toggleTheme}
className="p-2.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label="Přepnout téma"
aria-label={t("common.toggleTheme")}
>
{theme === "dark" ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -82,7 +84,7 @@ export default function Header() {
onClick={handleLogout}
className="text-sm px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors min-h-[44px] flex items-center"
>
Odhlásit
{t("auth.logout")}
</button>
</div>
) : (
@@ -90,7 +92,7 @@ export default function Header() {
href="/login"
className="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors min-h-[44px] flex items-center"
>
Přihlásit
{t("auth.submit")}
</Link>
)}
</div>
@@ -101,7 +103,7 @@ export default function Header() {
<button
onClick={openDrawer}
className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-bold cursor-pointer"
aria-label="Otevřít menu"
aria-label={t("common.menu")}
>
{(user.name || user.email || "?").charAt(0).toUpperCase()}
</button>
@@ -109,7 +111,7 @@ export default function Header() {
<button
onClick={openDrawer}
className="p-2.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label="Menu"
aria-label={t("common.menu")}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
@@ -132,11 +134,11 @@ export default function Header() {
<div className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-gray-900 shadow-2xl animate-slideInRight">
{/* Close button */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<span className="font-semibold text-lg">Menu</span>
<span className="font-semibold text-lg">{t("common.menu")}</span>
<button
onClick={closeDrawer}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label="Zavřít menu"
aria-label={t("common.closeMenu")}
>
<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" />
@@ -152,7 +154,7 @@ export default function Header() {
{(user.name || user.email || "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{user.name || "Uživatel"}</p>
<p className="font-medium text-sm truncate">{user.name || t("settings.user")}</p>
<p className="text-xs text-muted truncate">{user.email}</p>
</div>
</div>
@@ -169,7 +171,7 @@ export default function Header() {
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm font-medium">Úkoly</span>
<span className="text-sm font-medium">{t("nav.tasks")}</span>
</Link>
<Link
@@ -180,7 +182,7 @@ export default function Header() {
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">Kalendář</span>
<span className="text-sm font-medium">{t("nav.calendar")}</span>
</Link>
<Link
@@ -191,7 +193,7 @@ export default function Header() {
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="text-sm font-medium">Chat</span>
<span className="text-sm font-medium">{t("nav.chat")}</span>
</Link>
<Link
@@ -203,7 +205,7 @@ export default function Header() {
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm font-medium">Nastavení</span>
<span className="text-sm font-medium">{t("nav.settings")}</span>
</Link>
{/* Theme toggle */}
@@ -221,7 +223,7 @@ export default function Header() {
</svg>
)}
<span className="text-sm font-medium">
{theme === "dark" ? "Světlý režim" : "Tmavý režim"}
{theme === "dark" ? t("settings.light") : t("settings.dark")}
</span>
</button>
</div>
@@ -236,7 +238,7 @@ export default function Header() {
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span className="text-sm font-medium">Odhlásit se</span>
<span className="text-sm font-medium">{t("auth.logout")}</span>
</button>
</div>
)}

View File

@@ -149,3 +149,58 @@ export interface Connector {
type: string;
config: Record<string, unknown>;
}
// Goals
export interface Goal {
id: string;
user_id: string | null;
title: string;
target_date: string | null;
progress_pct: number;
group_id: string | null;
plan: Record<string, unknown> | null;
created_at: string;
updated_at: string;
group_name: string | null;
group_color: string | null;
group_icon: string | null;
tasks?: Task[];
}
export interface GoalPlanResult {
plan: Record<string, unknown>;
tasks_created: number;
}
export interface GoalReport {
report: string;
stats: { done: number; total: number; pct: number };
}
export function getGoals(token: string) {
return apiFetch<{ data: Goal[] }>("/api/v1/goals", { token });
}
export function getGoal(token: string, id: string) {
return apiFetch<{ data: Goal }>(`/api/v1/goals/${id}`, { token });
}
export function createGoal(token: string, data: Partial<Goal>) {
return apiFetch<{ data: Goal }>("/api/v1/goals", { method: "POST", body: data, token });
}
export function updateGoal(token: string, id: string, data: Partial<Goal>) {
return apiFetch<{ data: Goal }>(`/api/v1/goals/${id}`, { method: "PUT", body: data, token });
}
export function deleteGoal(token: string, id: string) {
return apiFetch<void>(`/api/v1/goals/${id}`, { method: "DELETE", token });
}
export function generateGoalPlan(token: string, id: string) {
return apiFetch<{ data: GoalPlanResult }>(`/api/v1/goals/${id}/plan`, { method: "POST", token });
}
export function getGoalReport(token: string, id: string) {
return apiFetch<{ data: GoalReport }>(`/api/v1/goals/${id}/report`, { token });
}

99
apps/tasks/lib/i18n.tsx Normal file
View File

@@ -0,0 +1,99 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import cs from "@/messages/cs.json";
import he from "@/messages/he.json";
import ru from "@/messages/ru.json";
import ua from "@/messages/ua.json";
export type Locale = "cs" | "he" | "ru" | "ua";
export const LOCALES: { code: Locale; label: string; flag: string; dir: "ltr" | "rtl" }[] = [
{ code: "cs", label: "\u010ce\u0161tina", flag: "\ud83c\udde8\ud83c\uddff", dir: "ltr" },
{ code: "he", label: "\u05e2\u05d1\u05e8\u05d9\u05ea", flag: "\ud83c\uddee\ud83c\uddf1", dir: "rtl" },
{ code: "ru", label: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", flag: "\ud83c\uddf7\ud83c\uddfa", dir: "ltr" },
{ code: "ua", label: "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", flag: "\ud83c\uddfa\ud83c\udde6", dir: "ltr" },
];
type Messages = typeof cs;
const MESSAGES: Record<Locale, Messages> = { cs, he, ru, ua };
const STORAGE_KEY = "taskteam_language";
function getNestedValue(obj: unknown, path: string): string {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return path;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : path;
}
interface I18nContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
dir: "ltr" | "rtl";
isRTL: boolean;
}
const I18nContext = createContext<I18nContextType>({
locale: "cs",
setLocale: () => {},
t: (key: string) => key,
dir: "ltr",
isRTL: false,
});
export function useTranslation() {
return useContext(I18nContext);
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("cs");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (stored && MESSAGES[stored]) {
setLocaleState(stored);
}
}
}, []);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, newLocale);
}
}, []);
const t = useCallback(
(key: string): string => {
return getNestedValue(MESSAGES[locale], key);
},
[locale]
);
const localeInfo = LOCALES.find((l) => l.code === locale) || LOCALES[0];
const dir = localeInfo.dir;
const isRTL = dir === "rtl";
// Update html attributes when locale changes
useEffect(() => {
if (!mounted) return;
document.documentElement.lang = locale;
document.documentElement.dir = dir;
}, [locale, dir, mounted]);
return (
<I18nContext.Provider value={{ locale, setLocale, t, dir, isRTL }}>
{children}
</I18nContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
{
"nav": { "tasks": "Úkoly", "calendar": "Kalendář", "chat": "Chat", "settings": "Nastavení", "goals": "Cíle" },
"auth": { "login": "Přihlášení", "register": "Registrace", "email": "Email", "name": "Jméno", "phone": "Telefon", "submit": "Přihlásit se", "registerBtn": "Registrovat se", "noAccount": "Nemáte účet?", "hasAccount": "Máte účet?", "logout": "Odhlásit se" },
"tasks": { "title": "Úkoly", "add": "Nový úkol", "edit": "Upravit", "delete": "Smazat", "noTasks": "Žádné úkoly", "all": "Vše", "status": { "pending": "Čeká", "in_progress": "Probíhá", "done": "Hotovo", "completed": "Hotovo", "cancelled": "Zrušeno" }, "priority": { "urgent": "Urgentní", "high": "Vysoká", "medium": "Střední", "low": "Nízká" }, "form": { "title": "Název", "description": "Popis", "group": "Skupina", "priority": "Priorita", "status": "Status", "dueDate": "Termín", "save": "Uložit", "cancel": "Zrušit", "titleRequired": "Název je povinný", "saveError": "Chyba při ukládání", "saving": "Ukládám...", "noGroup": "-- Bez skupiny --", "placeholder": "Co je třeba udělat...", "descPlaceholder": "Podrobnosti..." }, "noDue": "Bez termínu", "createFirst": "Vytvořte první úkol pomocí tlačítka +", "newTask": "Nový úkol", "close": "Zavřít", "markDone": "Označit jako hotové", "start": "Zahájit", "reopen": "Znovu otevřít", "confirmDelete": "Opravdu smazat tento úkol?", "editTask": "Upravit úkol", "saveChanges": "Uložit změny", "deleting": "Mažu...", "created": "Vytvořeno", "completed": "Dokončeno", "loadError": "Chyba při načítání úkolu", "notFound": "Úkol nenalezen", "backToTasks": "Zpět na úkoly" },
"chat": { "title": "AI Asistent", "placeholder": "Napište zprávu...", "send": "Odeslat", "empty": "Zeptejte se na cokoliv...", "subtitle": "Zeptejte se na cokoliv ohledně vašich úkolů", "startConversation": "Začněte konverzaci", "helpText": "Napište zprávu a AI asistent vám pomůže s úkoly", "unavailable": "Chat asistent je momentálně nedostupný. Zkuste to prosím později.", "processError": "Omlouvám se, nemohl jsem zpracovat vaši zprávu." },
"settings": { "title": "Nastavení", "language": "Jazyk", "theme": "Motiv", "dark": "Tmavý režim", "light": "Světlý režim", "notifications": "Oznámení", "push": "Push oznámení", "email": "E-mailová oznámení", "taskReminders": "Připomenutí úkolů", "dailySummary": "Denní souhrn", "save": "Uložit nastavení", "saved": "Uloženo!", "profile": "Profil", "appearance": "Vzhled", "user": "Uživatel" },
"goals": { "title": "Cíle", "add": "Nový cíl", "progress": "Progres", "plan": "Generovat plán", "report": "AI Report" },
"common": { "back": "Zpět", "loading": "Načítání...", "error": "Chyba", "confirm": "Potvrdit", "menu": "Menu", "closeMenu": "Zavřít menu", "toggleTheme": "Přepnout téma" },
"calendar": { "title": "Kalendář" }
}

View File

@@ -0,0 +1,10 @@
{
"nav": { "tasks": "משימות", "calendar": "לוח שנה", "chat": "צ׳אט", "settings": "הגדרות", "goals": "מטרות" },
"auth": { "login": "התחברות", "register": "הרשמה", "email": "אימייל", "name": "שם", "phone": "טלפון", "submit": "התחבר", "registerBtn": "הירשם", "noAccount": "אין לך חשבון?", "hasAccount": "יש לך חשבון?", "logout": "התנתק" },
"tasks": { "title": "משימות", "add": "משימה חדשה", "edit": "ערוך", "delete": "מחק", "noTasks": "אין משימות", "all": "הכל", "status": { "pending": "ממתין", "in_progress": "בתהליך", "done": "הושלם", "completed": "הושלם", "cancelled": "בוטל" }, "priority": { "urgent": "דחוף", "high": "גבוה", "medium": "בינוני", "low": "נמוך" }, "form": { "title": "כותרת", "description": "תיאור", "group": "קבוצה", "priority": "עדיפות", "status": "סטטוס", "dueDate": "תאריך יעד", "save": "שמור", "cancel": "ביטול", "titleRequired": "כותרת חובה", "saveError": "שגיאה בשמירה", "saving": "שומר...", "noGroup": "-- ללא קבוצה --", "placeholder": "מה צריך לעשות...", "descPlaceholder": "פרטים..." }, "noDue": "ללא תאריך", "createFirst": "צור משימה ראשונה בעזרת הכפתור +", "newTask": "משימה חדשה", "close": "סגור", "markDone": "סמן כהושלם", "start": "התחל", "reopen": "פתח מחדש", "confirmDelete": "למחוק משימה זו?", "editTask": "ערוך משימה", "saveChanges": "שמור שינויים", "deleting": "מוחק...", "created": "נוצר", "completed": "הושלם", "loadError": "שגיאה בטעינת המשימה", "notFound": "משימה לא נמצאה", "backToTasks": "חזרה למשימות" },
"chat": { "title": "עוזר AI", "placeholder": "כתוב הודעה...", "send": "שלח", "empty": "שאל כל דבר...", "subtitle": "שאל כל שאלה לגבי המשימות שלך", "startConversation": "התחל שיחה", "helpText": "כתוב הודעה ועוזר ה-AI יעזור לך עם משימות", "unavailable": "עוזר הצ׳אט אינו זמין כרגע. נסה שוב מאוחר יותר.", "processError": "מצטער, לא הצלחתי לעבד את ההודעה שלך." },
"settings": { "title": "הגדרות", "language": "שפה", "theme": "ערכת נושא", "dark": "מצב כהה", "light": "מצב בהיר", "notifications": "התראות", "push": "התראות פוש", "email": "התראות אימייל", "taskReminders": "תזכורות משימות", "dailySummary": "סיכום יומי", "save": "שמור הגדרות", "saved": "נשמר!", "profile": "פרופיל", "appearance": "מראה", "user": "משתמש" },
"goals": { "title": "מטרות", "add": "מטרה חדשה", "progress": "התקדמות", "plan": "צור תוכנית", "report": "דוח AI" },
"common": { "back": "חזרה", "loading": "טוען...", "error": "שגיאה", "confirm": "אישור", "menu": "תפריט", "closeMenu": "סגור תפריט", "toggleTheme": "החלף ערכת נושא" },
"calendar": { "title": "לוח שנה" }
}

View File

@@ -0,0 +1,10 @@
{
"nav": { "tasks": "Задачи", "calendar": "Календарь", "chat": "Чат", "settings": "Настройки", "goals": "Цели" },
"auth": { "login": "Вход", "register": "Регистрация", "email": "Email", "name": "Имя", "phone": "Телефон", "submit": "Войти", "registerBtn": "Зарегистрироваться", "noAccount": "Нет аккаунта?", "hasAccount": "Есть аккаунт?", "logout": "Выйти" },
"tasks": { "title": "Задачи", "add": "Новая задача", "edit": "Редактировать", "delete": "Удалить", "noTasks": "Нет задач", "all": "Все", "status": { "pending": "Ожидает", "in_progress": "В работе", "done": "Готово", "completed": "Готово", "cancelled": "Отменено" }, "priority": { "urgent": "Срочно", "high": "Высокий", "medium": "Средний", "low": "Низкий" }, "form": { "title": "Название", "description": "Описание", "group": "Группа", "priority": "Приоритет", "status": "Статус", "dueDate": "Срок", "save": "Сохранить", "cancel": "Отмена", "titleRequired": "Название обязательно", "saveError": "Ошибка при сохранении", "saving": "Сохраняю...", "noGroup": "-- Без группы --", "placeholder": "Что нужно сделать...", "descPlaceholder": "Подробности..." }, "noDue": "Без срока", "createFirst": "Создайте первую задачу кнопкой +", "newTask": "Новая задача", "close": "Закрыть", "markDone": "Отметить готовой", "start": "Начать", "reopen": "Открыть заново", "confirmDelete": "Удалить эту задачу?", "editTask": "Редактировать задачу", "saveChanges": "Сохранить изменения", "deleting": "Удаляю...", "created": "Создано", "completed": "Завершено", "loadError": "Ошибка при загрузке задачи", "notFound": "Задача не найдена", "backToTasks": "Назад к задачам" },
"chat": { "title": "AI Ассистент", "placeholder": "Напишите сообщение...", "send": "Отправить", "empty": "Спросите что угодно...", "subtitle": "Задайте любой вопрос о ваших задачах", "startConversation": "Начните разговор", "helpText": "Напишите сообщение, и AI ассистент поможет вам с задачами", "unavailable": "Чат ассистент сейчас недоступен. Попробуйте позже.", "processError": "Извините, не удалось обработать ваше сообщение." },
"settings": { "title": "Настройки", "language": "Язык", "theme": "Тема", "dark": "Тёмный режим", "light": "Светлый режим", "notifications": "Уведомления", "push": "Push уведомления", "email": "E-mail уведомления", "taskReminders": "Напоминания о задачах", "dailySummary": "Ежедневная сводка", "save": "Сохранить настройки", "saved": "Сохранено!", "profile": "Профиль", "appearance": "Внешний вид", "user": "Пользователь" },
"goals": { "title": "Цели", "add": "Новая цель", "progress": "Прогресс", "plan": "Создать план", "report": "AI Отчёт" },
"common": { "back": "Назад", "loading": "Загрузка...", "error": "Ошибка", "confirm": "Подтвердить", "menu": "Меню", "closeMenu": "Закрыть меню", "toggleTheme": "Переключить тему" },
"calendar": { "title": "Календарь" }
}

View File

@@ -0,0 +1,10 @@
{
"nav": { "tasks": "Завдання", "calendar": "Календар", "chat": "Чат", "settings": "Налаштування", "goals": "Цілі" },
"auth": { "login": "Вхід", "register": "Реєстрація", "email": "Email", "name": "Ім'я", "phone": "Телефон", "submit": "Увійти", "registerBtn": "Зареєструватися", "noAccount": "Немає акаунту?", "hasAccount": "Є акаунт?", "logout": "Вийти" },
"tasks": { "title": "Завдання", "add": "Нове завдання", "edit": "Редагувати", "delete": "Видалити", "noTasks": "Немає завдань", "all": "Усі", "status": { "pending": "Очікує", "in_progress": "В роботі", "done": "Готово", "completed": "Готово", "cancelled": "Скасовано" }, "priority": { "urgent": "Терміново", "high": "Високий", "medium": "Середній", "low": "Низький" }, "form": { "title": "Назва", "description": "Опис", "group": "Група", "priority": "Пріоритет", "status": "Статус", "dueDate": "Термін", "save": "Зберегти", "cancel": "Скасувати", "titleRequired": "Назва обов'язкова", "saveError": "Помилка при збереженні", "saving": "Зберігаю...", "noGroup": "-- Без групи --", "placeholder": "Що треба зробити...", "descPlaceholder": "Подробиці..." }, "noDue": "Без терміну", "createFirst": "Створіть перше завдання кнопкою +", "newTask": "Нове завдання", "close": "Закрити", "markDone": "Позначити готовим", "start": "Розпочати", "reopen": "Відкрити знову", "confirmDelete": "Видалити це завдання?", "editTask": "Редагувати завдання", "saveChanges": "Зберегти зміни", "deleting": "Видаляю...", "created": "Створено", "completed": "Завершено", "loadError": "Помилка при завантаженні завдання", "notFound": "Завдання не знайдено", "backToTasks": "Назад до завдань" },
"chat": { "title": "AI Асистент", "placeholder": "Напишіть повідомлення...", "send": "Надіслати", "empty": "Запитайте будь-що...", "subtitle": "Задайте будь-яке питання щодо ваших завдань", "startConversation": "Почніть розмову", "helpText": "Напишіть повідомлення, і AI асистент допоможе вам із завданнями", "unavailable": "Чат асистент зараз недоступний. Спробуйте пізніше.", "processError": "Вибачте, не вдалося обробити ваше повідомлення." },
"settings": { "title": "Налаштування", "language": "Мова", "theme": "Тема", "dark": "Темний режим", "light": "Світлий режим", "notifications": "Сповіщення", "push": "Push сповіщення", "email": "E-mail сповіщення", "taskReminders": "Нагадування про завдання", "dailySummary": "Щоденний підсумок", "save": "Зберегти налаштування", "saved": "Збережено!", "profile": "Профіль", "appearance": "Зовнішній вигляд", "user": "Користувач" },
"goals": { "title": "Цілі", "add": "Нова ціль", "progress": "Прогрес", "plan": "Створити план", "report": "AI Звіт" },
"common": { "back": "Назад", "loading": "Завантаження...", "error": "Помилка", "confirm": "Підтвердити", "menu": "Меню", "closeMenu": "Закрити меню", "toggleTheme": "Перемкнути тему" },
"calendar": { "title": "Календар" }
}