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:
89
api/package-lock.json
generated
89
api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
155
api/src/routes/goals.js
Normal 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;
|
||||
77
api/src/routes/notifications.js
Normal file
77
api/src/routes/notifications.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
482
apps/tasks/app/goals/page.tsx
Normal file
482
apps/tasks/app/goals/page.tsx
Normal 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">🎯</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
99
apps/tasks/lib/i18n.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/tasks/messages/cs.json
Normal file
10
apps/tasks/messages/cs.json
Normal 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ář" }
|
||||
}
|
||||
10
apps/tasks/messages/he.json
Normal file
10
apps/tasks/messages/he.json
Normal 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": "לוח שנה" }
|
||||
}
|
||||
10
apps/tasks/messages/ru.json
Normal file
10
apps/tasks/messages/ru.json
Normal 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": "Календарь" }
|
||||
}
|
||||
10
apps/tasks/messages/ua.json
Normal file
10
apps/tasks/messages/ua.json
Normal 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": "Календар" }
|
||||
}
|
||||
Reference in New Issue
Block a user