Compare commits

...

10 Commits

Author SHA1 Message Date
926a584789 WebAuthn biometric + PWA widget + UI header fixes + mobile responsive
- WebAuthn: register/auth options, device management
- PWA widget page + manifest shortcuts
- Group schedule endpoint (timezones + locations)
- UI #3-#6: compact headers on tasks/calendar/projects/goals
- UI #9: mobile responsive top bars
- webauthn_credentials table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:54:54 +00:00
6d68b68412 UI icon-only buttons: 9 components, compact header, inline edit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:18:23 +00:00
8cf14dcf59 Feature pack: Media, Gamification, Templates, Time Tracking, Kanban, AI Briefing, Webhooks, Icon UI
API features (separate files in /api/src/features/):
- media-input: upload text/audio/photo/video, transcription
- gamification: points, streaks, badges, leaderboard
- templates: predefined task sets (sprint, study, moving)
- time-tracking: start/stop timer, task/user reports
- kanban: board view, drag-and-drop move
- ai-briefing: daily AI summary with tasks/goals/reviews
- webhooks-outgoing: notify external systems on events

UI components (separate files in /components/features/):
- IconButton: icon-only buttons with tooltip
- CompactHeader, PageActionBar, InlineEditField
- TaskDetailActions, GoalActionButtons, CollabActionButtons
- DeleteIconButton, CollabBackButton

All features modular — registry.js enables/disables each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:13:09 +00:00
f9c4ec631c feat: add media-input, gamification, and templates features
- media-input: universal media upload (text/audio/photo/video) with base64 encoding, file storage, and transcription stub
- gamification: points, streaks, levels, badges, leaderboard with auto-leveling
- templates: predefined task sets with 3 default templates (Weekly Sprint, Study Plan, Moving Checklist)
- All features registered via modular registry.js for easy enable/disable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:13:07 +00:00
0c3fc44440 Notification prefs per task + Odoo module management
- notification_prefs table (remind_before, on_due, daily, channels)
- GET/PUT /notification-prefs/:taskId
- GET /odoo/modules, POST /odoo/modules/install, GET /odoo/status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:57:30 +00:00
83febef040 Add admin dashboard frontend page
- Overview tab: stat cards (users, tasks, goals, projects, AI msgs, errors) + daily activity bar chart
- Users tab: user list with task/goal counts and delete capability
- Activity tab: recent activity feed with type badges
- Tailwind-based responsive design matching existing app style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:55:26 +00:00
42881b1f5a Add activity monitor, family workspace, per-user rate limiting
- Activity monitor API: phone usage tracking with report/summary/daily/ai-analysis endpoints
- Family workspace: shared task groups with member management
- Per-user API rate limiting: JWT-based key generator with IP fallback
- Also includes previously uncommitted spaced-repetition and admin routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:54:38 +00:00
524025bfe9 UI redesign: status dots with pulse + avatar circles + one-row header
- TaskCard: overlapping avatars, colored status dot (pulse for active)
- Header: group dropdown + status pills in single 40px row
- CSS: statusPulse animation
- Group interface: display_name optional field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:45:18 +00:00
e250b2124f Fix display_name TS error + clean rebuild
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:42:40 +00:00
317672aa08 Add invite button and InviteModal to task detail page
Adds the 'Pozvat' button to the collaboration section of the task
detail page, opening a modal that creates invitations and shows
WhatsApp/Telegram/SMS/Copy share links.
2026-03-30 00:34:12 +00:00
44 changed files with 2972 additions and 610 deletions

187
api/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^12.0.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.3.1",
@@ -389,12 +390,24 @@
"ws": "^8.16.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@@ -404,6 +417,64 @@
"node": ">=8"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz",
"integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz",
"integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
"license": "MIT",
"dependencies": {
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz",
"integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -479,6 +550,33 @@
"@redis/client": "^5.11.0"
}
},
"node_modules/@simplewebauthn/server": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-12.0.0.tgz",
"integrity": "sha512-aJdTe9GikOk40U7Q5Mm/Sqkxcq4a2oPZAcLcnyqMyFqrUaOS6vdsZW8/H3Mnsw9umcr88pcgB7kozPPt+5wOBw==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@simplewebauthn/types": "^12.0.0",
"cross-fetch": "^4.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -553,6 +651,20 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -733,6 +845,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-fetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1425,6 +1546,26 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -1813,6 +1954,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -2163,12 +2322,24 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
@@ -2229,6 +2400,22 @@
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -21,6 +21,7 @@
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^12.0.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.3.1",

View File

@@ -0,0 +1,28 @@
// Task Team — AI Daily Briefing
const Anthropic = require("@anthropic-ai/sdk");
async function aiBriefingFeature(app) {
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
app.get("/briefing/:userId", async (req) => {
const { rows: tasks } = await app.db.query(
"SELECT title, status, priority, due_at, group_id FROM tasks WHERE user_id=$1 AND status NOT IN ('done','completed','cancelled') ORDER BY priority DESC, due_at ASC NULLS LAST LIMIT 20",
[req.params.userId]
);
const { rows: goals } = await app.db.query(
"SELECT title, progress_pct FROM goals WHERE user_id=$1 LIMIT 5", [req.params.userId]
);
const { rows: reviews } = await app.db.query(
"SELECT count(*) as due FROM review_items WHERE user_id=$1 AND next_review <= NOW()", [req.params.userId]
);
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 512,
system: "Jsi osobni asistent. Dej strucny ranni briefing v cestine. Bud prakticky a motivujici.",
messages: [{ role: "user", content: `Ranni briefing pro uzivatele:\nUkoly (${tasks.length}): ${JSON.stringify(tasks.slice(0,5).map(t=>t.title))}\nCile: ${JSON.stringify(goals.map(g=>g.title+" "+g.progress_pct+"%"))}\nKarty k opakovani: ${reviews[0]?.due || 0}\nDnes je ${new Date().toLocaleDateString("cs-CZ",{weekday:"long",day:"numeric",month:"long"})}` }]
});
return { data: { briefing: response.content[0].text, tasks_count: tasks.length, reviews_due: parseInt(reviews[0]?.due || 0) } };
});
}
module.exports = aiBriefingFeature;

View File

@@ -0,0 +1,73 @@
// Task Team — Gamification — points, streaks, badges
async function gamificationFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS user_stats (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
points INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
streak_days INTEGER DEFAULT 0,
longest_streak INTEGER DEFAULT 0,
tasks_completed INTEGER DEFAULT 0,
last_active DATE DEFAULT CURRENT_DATE,
badges JSONB DEFAULT '[]'
);
`).catch(() => {});
// Get user stats
app.get("/gamification/stats/:userId", async (req) => {
let { rows } = await app.db.query("SELECT * FROM user_stats WHERE user_id=$1", [req.params.userId]);
if (!rows.length) {
await app.db.query("INSERT INTO user_stats (user_id) VALUES ($1) ON CONFLICT DO NOTHING", [req.params.userId]);
rows = (await app.db.query("SELECT * FROM user_stats WHERE user_id=$1", [req.params.userId])).rows;
}
const stats = rows[0];
stats.next_level_points = stats.level * 100;
stats.progress_pct = Math.min(100, Math.round((stats.points % (stats.level * 100)) / (stats.level * 100) * 100));
return { data: stats };
});
// Award points (called internally when task completed)
app.post("/gamification/award", async (req) => {
const { user_id, points, reason } = req.body;
const { rows } = await app.db.query(`
INSERT INTO user_stats (user_id, points, tasks_completed, last_active)
VALUES ($1, $2, 1, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET
points = user_stats.points + $2,
tasks_completed = user_stats.tasks_completed + 1,
streak_days = CASE
WHEN user_stats.last_active = CURRENT_DATE - 1 THEN user_stats.streak_days + 1
WHEN user_stats.last_active = CURRENT_DATE THEN user_stats.streak_days
ELSE 1
END,
longest_streak = GREATEST(user_stats.longest_streak,
CASE WHEN user_stats.last_active = CURRENT_DATE - 1 THEN user_stats.streak_days + 1 ELSE 1 END),
level = 1 + (user_stats.points + $2) / 100,
last_active = CURRENT_DATE
RETURNING *
`, [user_id, points || 10]);
return { data: rows[0] };
});
// Leaderboard
app.get("/gamification/leaderboard", async (req) => {
const { rows } = await app.db.query(
"SELECT us.*, u.name, u.avatar_url FROM user_stats us JOIN users u ON us.user_id=u.id ORDER BY us.points DESC LIMIT 20"
);
return { data: rows };
});
// Badges
app.get("/gamification/badges", async () => {
return { data: [
{ id: "first_task", name: "First Task", icon: "target", description: "Completed first task" },
{ id: "streak_7", name: "7 Day Streak", icon: "fire", description: "7 consecutive days active" },
{ id: "streak_30", name: "30 Day Streak", icon: "diamond", description: "30 consecutive days active" },
{ id: "collaborator", name: "Team Player", icon: "handshake", description: "Collaborated on 5+ tasks" },
{ id: "goal_master", name: "Goal Master", icon: "trophy", description: "Completed 3 goals" },
{ id: "speed_demon", name: "Speed Demon", icon: "lightning", description: "Completed 5 tasks in one day" },
{ id: "polyglot", name: "Polyglot", icon: "globe", description: "Used 3+ languages" }
]};
});
}
module.exports = gamificationFeature;

View File

@@ -0,0 +1,44 @@
// Task Team — Kanban Board — drag-and-drop columns
async function kanbanFeature(app) {
// Kanban uses existing tasks table, just provides board-view endpoints
app.get("/kanban/board", async (req) => {
const { group_id, project_id } = req.query;
let where = "1=1";
const params = [];
if (group_id) { params.push(group_id); where += ` AND t.group_id=$${params.length}`; }
if (project_id) { params.push(project_id); where += ` AND t.project_id=$${params.length}`; }
const columns = ["pending", "in_progress", "done", "cancelled"];
const board = {};
for (const col of columns) {
const colParams = [...params, col];
const { rows } = await app.db.query(
`SELECT t.id, t.title, t.priority, t.assigned_to, t.group_id, tg.color as group_color, tg.icon as group_icon
FROM tasks t LEFT JOIN task_groups tg ON t.group_id=tg.id
WHERE ${where} AND t.status=$${colParams.length}
ORDER BY t.created_at DESC LIMIT 50`, colParams
);
board[col] = rows;
}
return { data: board };
});
// Move task between columns (change status)
app.post("/kanban/move", async (req) => {
const { task_id, new_status } = req.body;
const valid = ["pending", "in_progress", "done", "completed", "cancelled"];
if (!valid.includes(new_status)) throw { statusCode: 400, message: "Invalid status" };
const sets = ["status=$1", "updated_at=NOW()"];
const params = [new_status];
if (new_status === "done" || new_status === "completed") sets.push("completed_at=NOW()");
params.push(task_id);
const { rows } = await app.db.query(
`UPDATE tasks SET ${sets.join(",")} WHERE id=$${params.length} RETURNING *`, params
);
return { data: rows[0] };
});
}
module.exports = kanbanFeature;

View File

@@ -0,0 +1,79 @@
// Task Team — Media Input — text, audio transcription, photo, video
const { writeFileSync, mkdirSync, existsSync } = require("fs");
const path = require("path");
const crypto = require("crypto");
const UPLOAD_DIR = "/opt/task-team/uploads";
async function mediaInputFeature(app) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
// Create media table
await app.db.query(`
CREATE TABLE IF NOT EXISTS media_attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID,
type VARCHAR(20) NOT NULL,
filename VARCHAR(500),
path TEXT,
size_bytes INTEGER DEFAULT 0,
mime_type VARCHAR(100),
transcription TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// Upload media (base64 encoded in JSON body for simplicity)
app.post("/media/upload", async (req) => {
const { task_id, user_id, type, data, filename, mime_type } = req.body;
// type: "text", "audio", "photo", "video"
let filePath = null;
let size = 0;
let transcription = null;
if (type === "text") {
transcription = data; // plain text, no file
} else if (data) {
// Save base64 file
const buffer = Buffer.from(data, "base64");
const ext = mime_type?.split("/")[1] || type;
const fname = crypto.randomBytes(8).toString("hex") + "." + ext;
const dir = path.join(UPLOAD_DIR, type);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
filePath = path.join(dir, fname);
writeFileSync(filePath, buffer);
size = buffer.length;
// Audio transcription via AI
if (type === "audio" && process.env.ANTHROPIC_API_KEY) {
transcription = "[Audio transcription pending — needs Whisper API]";
}
}
const { rows } = await app.db.query(
`INSERT INTO media_attachments (task_id, user_id, type, filename, path, size_bytes, mime_type, transcription)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[task_id, user_id, type, filename || "", filePath || "", size, mime_type || "", transcription || ""]
);
return { data: rows[0] };
});
// Get media for task
app.get("/media/task/:taskId", async (req) => {
const { rows } = await app.db.query(
"SELECT id, task_id, type, filename, size_bytes, mime_type, transcription, created_at FROM media_attachments WHERE task_id=$1 ORDER BY created_at DESC",
[req.params.taskId]
);
return { data: rows };
});
// Delete media
app.delete("/media/:id", async (req) => {
await app.db.query("DELETE FROM media_attachments WHERE id=$1", [req.params.id]);
return { status: "deleted" };
});
}
module.exports = mediaInputFeature;

View File

@@ -0,0 +1,25 @@
// Task Team — Feature Registry
// Each feature is a separate file. Add/remove here to enable/disable.
const features = [
{ name: "media-input", path: "./media-input", prefix: "/api/v1" },
{ name: "gamification", path: "./gamification", prefix: "/api/v1" },
{ name: "templates", path: "./templates", prefix: "/api/v1" },
{ name: "time-tracking", path: "./time-tracking", prefix: "/api/v1" },
{ name: "kanban", path: "./kanban", prefix: "/api/v1" },
{ name: "ai-briefing", path: "./ai-briefing", prefix: "/api/v1" },
{ name: "webhooks-outgoing", path: "./webhooks-outgoing", prefix: "/api/v1" },
{ name: "webauthn", path: "./webauthn", prefix: "/api/v1" },
];
async function registerFeatures(app) {
for (const f of features) {
try {
await app.register(require(f.path), { prefix: f.prefix });
app.log.info(`Feature loaded: ${f.name}`);
} catch (e) {
app.log.warn(`Feature ${f.name} failed to load: ${e.message}`);
}
}
}
module.exports = { registerFeatures };

View File

@@ -0,0 +1,87 @@
// Task Team — Task Templates — predefined task sets
async function templatesFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS task_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
category VARCHAR(50),
tasks JSONB NOT NULL DEFAULT '[]',
created_by UUID,
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// List templates
app.get("/templates", async (req) => {
const { rows } = await app.db.query("SELECT * FROM task_templates WHERE is_public=true ORDER BY name");
return { data: rows };
});
// Create template
app.post("/templates", async (req) => {
const { name, description, category, tasks, created_by } = req.body;
const { rows } = await app.db.query(
"INSERT INTO task_templates (name, description, category, tasks, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[name, description || "", category || "general", JSON.stringify(tasks || []), created_by]
);
return { data: rows[0] };
});
// Apply template (creates tasks from template)
app.post("/templates/:id/apply", async (req) => {
const { user_id, group_id } = req.body;
const { rows: templates } = await app.db.query("SELECT * FROM task_templates WHERE id=$1", [req.params.id]);
if (!templates.length) throw { statusCode: 404, message: "Template not found" };
const taskList = templates[0].tasks;
let created = 0;
for (const t of taskList) {
await app.db.query(
"INSERT INTO tasks (title, description, user_id, group_id, priority, status) VALUES ($1,$2,$3,$4,$5,$6)",
[t.title, t.description || "", user_id, group_id, t.priority || "medium", "pending"]
);
created++;
}
return { status: "ok", created, template: templates[0].name };
});
// Delete template
app.delete("/templates/:id", async (req) => {
await app.db.query("DELETE FROM task_templates WHERE id=$1", [req.params.id]);
return { status: "deleted" };
});
// Seed default templates
const { rows: existing } = await app.db.query("SELECT count(*) as c FROM task_templates");
if (parseInt(existing[0].c) === 0) {
const defaults = [
{ name: "Weekly Sprint", category: "work", tasks: [
{ title: "Sprint planning", priority: "high" },
{ title: "Daily standup notes", priority: "medium" },
{ title: "Code review", priority: "high" },
{ title: "Sprint retrospective", priority: "medium" }
]},
{ name: "Study Plan", category: "study", tasks: [
{ title: "Read chapter", priority: "medium" },
{ title: "Take notes", priority: "medium" },
{ title: "Practice exercises", priority: "high" },
{ title: "Review flashcards", priority: "low" },
{ title: "Weekly quiz", priority: "high" }
]},
{ name: "Moving Checklist", category: "personal", tasks: [
{ title: "Pack kitchen", priority: "high" },
{ title: "Notify utilities", priority: "high" },
{ title: "Change address", priority: "medium" },
{ title: "Clean old place", priority: "medium" },
{ title: "Unpack essentials", priority: "high" }
]}
];
for (const t of defaults) {
await app.db.query("INSERT INTO task_templates (name, category, tasks) VALUES ($1,$2,$3)",
[t.name, t.category, JSON.stringify(t.tasks)]);
}
}
}
module.exports = templatesFeature;

View File

@@ -0,0 +1,72 @@
// Task Team — Time Tracking — stopwatch on tasks
async function timeTrackingFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS time_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
note TEXT DEFAULT '\,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_time_task ON time_entries(task_id);
`).catch(() => {});
// Start timer
app.post("/time/start", async (req) => {
const { task_id, user_id } = req.body;
// Stop any running timer first
await app.db.query(
"UPDATE time_entries SET ended_at=NOW(), duration_seconds=EXTRACT(EPOCH FROM NOW()-started_at)::integer WHERE user_id=$1 AND ended_at IS NULL",
[user_id]
);
const { rows } = await app.db.query(
"INSERT INTO time_entries (task_id, user_id) VALUES ($1,$2) RETURNING *",
[task_id, user_id]
);
return { data: rows[0] };
});
// Stop timer
app.post("/time/stop", async (req) => {
const { user_id, note } = req.body;
const { rows } = await app.db.query(
"UPDATE time_entries SET ended_at=NOW(), duration_seconds=EXTRACT(EPOCH FROM NOW()-started_at)::integer, note=$2 WHERE user_id=$1 AND ended_at IS NULL RETURNING *",
[user_id, note || ""]
);
if (!rows.length) throw { statusCode: 404, message: "No running timer" };
return { data: rows[0] };
});
// Get active timer
app.get("/time/active/:userId", async (req) => {
const { rows } = await app.db.query(
"SELECT te.*, t.title as task_title FROM time_entries te JOIN tasks t ON te.task_id=t.id WHERE te.user_id=$1 AND te.ended_at IS NULL",
[req.params.userId]
);
return { data: rows[0] || null };
});
// Task time report
app.get("/time/task/:taskId", async (req) => {
const { rows } = await app.db.query(
"SELECT te.*, u.name as user_name FROM time_entries te JOIN users u ON te.user_id=u.id WHERE te.task_id=$1 ORDER BY te.started_at DESC",
[req.params.taskId]
);
const total = rows.reduce((s, r) => s + (r.duration_seconds || 0), 0);
return { data: rows, total_seconds: total, total_hours: Math.round(total / 36) / 100 };
});
// User weekly report
app.get("/time/report/:userId", async (req) => {
const { rows } = await app.db.query(`
SELECT date_trunc('day', started_at)::date as day, sum(duration_seconds) as seconds, count(*) as entries
FROM time_entries WHERE user_id=$1 AND started_at > NOW() - INTERVAL '7 days' AND duration_seconds IS NOT NULL
GROUP BY 1 ORDER BY 1
`, [req.params.userId]);
return { data: rows };
});
}
module.exports = timeTrackingFeature;

View File

@@ -0,0 +1,64 @@
// Task Team — WebAuthn Biometric Auth
// Note: Full WebAuthn needs @simplewebauthn/server, but we create the API structure
async function webauthnFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter INTEGER DEFAULT 0,
device_name VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// Registration options
app.post('/webauthn/register/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT id,email,name FROM users WHERE id=$1', [user_id]);
if (!rows.length) throw { statusCode: 404, message: 'User not found' };
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
rp: { name: 'Task Team', id: 'tasks.hasdo.info' },
user: { id: Buffer.from(rows[0].id).toString('base64url'), name: rows[0].email, displayName: rows[0].name },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }],
authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' },
timeout: 60000
}};
});
// Save registration
app.post('/webauthn/register/verify', async (req) => {
const { user_id, credential_id, public_key, device_name } = req.body;
const { rows } = await app.db.query(
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, device_name) VALUES ($1,$2,$3,$4) RETURNING *',
[user_id, credential_id, public_key, device_name || 'Biometric']);
return { data: rows[0], status: 'registered' };
});
// Auth options
app.post('/webauthn/auth/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]);
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
timeout: 60000, userVerification: 'required'
}};
});
// List user's biometric devices
app.get('/webauthn/devices/:userId', async (req) => {
const { rows } = await app.db.query(
'SELECT id, device_name, created_at FROM webauthn_credentials WHERE user_id=$1', [req.params.userId]);
return { data: rows };
});
// Remove device
app.delete('/webauthn/devices/:id', async (req) => {
await app.db.query('DELETE FROM webauthn_credentials WHERE id=$1', [req.params.id]);
return { status: 'deleted' };
});
}
module.exports = webauthnFeature;

View File

@@ -0,0 +1,57 @@
// Task Team — Outgoing Webhooks — notify external systems
async function webhooksFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS webhook_endpoints (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
events TEXT[] DEFAULT '{"task.created","task.completed"}',
secret VARCHAR(64),
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
app.get("/webhooks", async (req) => {
const { user_id } = req.query;
const { rows } = await app.db.query("SELECT id,url,events,active,created_at FROM webhook_endpoints WHERE user_id=$1", [user_id]);
return { data: rows };
});
app.post("/webhooks", async (req) => {
const { user_id, url, events, secret } = req.body;
const crypto = require("crypto");
const { rows } = await app.db.query(
"INSERT INTO webhook_endpoints (user_id, url, events, secret) VALUES ($1,$2,$3,$4) RETURNING *",
[user_id, url, events || ["task.created","task.completed"], secret || crypto.randomBytes(16).toString("hex")]
);
return { data: rows[0] };
});
app.delete("/webhooks/:id", async (req) => {
await app.db.query("DELETE FROM webhook_endpoints WHERE id=$1", [req.params.id]);
return { status: "deleted" };
});
// Fire webhook (internal — called from task routes)
app.post("/webhooks/fire", async (req) => {
const { event, payload, user_id } = req.body;
const { rows } = await app.db.query(
"SELECT * FROM webhook_endpoints WHERE user_id=$1 AND active=true AND $2=ANY(events)",
[user_id, event]
);
let sent = 0;
for (const wh of rows) {
try {
await fetch(wh.url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Webhook-Secret": wh.secret || "" },
body: JSON.stringify({ event, payload, timestamp: new Date().toISOString() })
});
sent++;
} catch {}
}
return { status: "ok", sent, total: rows.length };
});
}
module.exports = webhooksFeature;

View File

@@ -48,7 +48,17 @@ const start = async () => {
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
keyGenerator: (req) => req.ip,
keyGenerator: (req) => {
// Use user ID if authenticated, otherwise IP
try {
const token = req.headers.authorization?.split(" ")[1];
if (token) {
const decoded = app.jwt.decode(token);
return `user:${decoded.id}`;
}
} catch {}
return `ip:${req.ip}`;
},
errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
});
await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
@@ -60,8 +70,6 @@ const start = async () => {
pid: process.pid,
redis: redis.status
}));
// Swagger/OpenAPI documentation
await app.register(swagger, {
openapi: {
@@ -115,7 +123,16 @@ const start = async () => {
await app.register(require("./routes/email"), { prefix: "/api/v1" });
await app.register(require("./routes/errors"), { prefix: "/api/v1" });
await app.register(require("./routes/invitations"), { prefix: "/api/v1" });
await app.register(require("./routes/notification-prefs"), { prefix: "/api/v1" });
await app.register(require("./routes/odoo-modules"), { prefix: "/api/v1" });
await app.register(require("./routes/spaced-repetition"), { prefix: "/api/v1" });
await app.register(require("./routes/admin"), { prefix: "/api/v1" });
await app.register(require("./routes/activity"), { prefix: "/api/v1" });
await app.register(require("./routes/families"), { prefix: "/api/v1" });
// Register features (modular)
const { registerFeatures } = require("./features/registry");
await registerFeatures(app);
try {
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");

View File

@@ -0,0 +1,85 @@
// Task Team — Activity Monitor — 2026-03-30
async function activityRoutes(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS activity_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
app_name VARCHAR(255) NOT NULL,
package_name VARCHAR(255),
duration_seconds INTEGER DEFAULT 0,
category VARCHAR(50),
recorded_at TIMESTAMPTZ DEFAULT NOW(),
device VARCHAR(100)
);
CREATE INDEX IF NOT EXISTS idx_activity_user ON activity_logs(user_id, recorded_at DESC);
`).catch(() => {});
// Report activity from phone
app.post("/activity/report", async (req) => {
const { user_id, activities } = req.body;
// activities: [{app_name, package_name, duration_seconds, category}]
let inserted = 0;
for (const a of (activities || [])) {
await app.db.query(
"INSERT INTO activity_logs (user_id, app_name, package_name, duration_seconds, category, device) VALUES ($1,$2,$3,$4,$5,$6)",
[user_id, a.app_name, a.package_name || "", a.duration_seconds || 0, a.category || "other", a.device || "android"]
);
inserted++;
}
return { status: "ok", inserted };
});
// Get activity summary
app.get("/activity/summary", async (req) => {
const { user_id, days } = req.query;
const d = parseInt(days) || 7;
const { rows } = await app.db.query(`
SELECT category, sum(duration_seconds) as total_seconds, count(*) as sessions
FROM activity_logs
WHERE user_id = $1 AND recorded_at > NOW() - make_interval(days => $2)
GROUP BY category ORDER BY total_seconds DESC
`, [user_id, d]);
return { data: rows };
});
// Get daily breakdown
app.get("/activity/daily", async (req) => {
const { user_id } = req.query;
const { rows } = await app.db.query(`
SELECT date_trunc('day', recorded_at)::date as day,
sum(duration_seconds) as total_seconds,
count(DISTINCT app_name) as apps_used
FROM activity_logs WHERE user_id = $1 AND recorded_at > NOW() - INTERVAL '7 days'
GROUP BY 1 ORDER BY 1
`, [user_id]);
return { data: rows };
});
// AI analysis of activity vs planned tasks
app.get("/activity/ai-analysis", async (req) => {
const { user_id } = req.query;
const { rows: activities } = await app.db.query(
`SELECT app_name, category, sum(duration_seconds) as total FROM activity_logs
WHERE user_id=$1 AND recorded_at > NOW() - INTERVAL '7 days' GROUP BY 1,2 ORDER BY total DESC LIMIT 10`,
[user_id]
);
const { rows: tasks } = await app.db.query(
"SELECT title, status, group_id FROM tasks WHERE user_id=$1 ORDER BY created_at DESC LIMIT 10",
[user_id]
);
if (!activities.length) return { data: { message: "No activity data yet" } };
const Anthropic = require("@anthropic-ai/sdk");
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 512,
system: "Analyzuj pouzivani telefonu vs planovane ukoly. Odpovez cesky, strucne.",
messages: [{ role: "user", content: `Aktivity: ${JSON.stringify(activities)}\nUkoly: ${JSON.stringify(tasks)}\nAnalyzuj: jsou aktivity v souladu s ukoly?` }]
});
return { data: { analysis: response.content[0].text, activities, tasks } };
});
}
module.exports = activityRoutes;

67
api/src/routes/admin.js Normal file
View File

@@ -0,0 +1,67 @@
// Task Team — Admin Dashboard — 2026-03-30
async function adminRoutes(app) {
// User management
app.get("/admin/users", async (req) => {
const { rows } = await app.db.query(
`SELECT u.id, u.email, u.name, u.phone, u.language, u.auth_provider, u.created_at,
(SELECT count(*) FROM tasks WHERE user_id=u.id) as task_count,
(SELECT count(*) FROM goals WHERE user_id=u.id) as goal_count
FROM users u ORDER BY u.created_at DESC`
);
return { data: rows };
});
app.delete("/admin/users/:id", async (req) => {
await app.db.query("DELETE FROM users WHERE id = $1", [req.params.id]);
return { status: "deleted" };
});
// System analytics
app.get("/admin/analytics", async (req) => {
const { rows: overview } = await app.db.query(`
SELECT
(SELECT count(*) FROM users) as total_users,
(SELECT count(*) FROM users WHERE created_at > NOW() - INTERVAL '7 days') as new_users_7d,
(SELECT count(*) FROM tasks) as total_tasks,
(SELECT count(*) FROM tasks WHERE status='completed' OR status='done') as completed_tasks,
(SELECT count(*) FROM tasks WHERE created_at > NOW() - INTERVAL '24 hours') as tasks_today,
(SELECT count(*) FROM goals) as total_goals,
(SELECT count(*) FROM invitations WHERE status='accepted') as accepted_invites,
(SELECT count(*) FROM task_comments WHERE is_ai=true) as ai_messages,
(SELECT count(*) FROM error_logs WHERE created_at > NOW() - INTERVAL '24 hours') as errors_24h,
(SELECT count(*) FROM projects) as total_projects
`);
// Daily activity (last 7 days)
const { rows: daily } = await app.db.query(`
SELECT date_trunc('day', created_at)::date as day, count(*) as tasks_created
FROM tasks WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY 1 ORDER BY 1
`);
return { data: { overview: overview[0], daily } };
});
// Recent activity feed
app.get("/admin/activity", async (req) => {
const { rows } = await app.db.query(`
(SELECT 'task_created' as type, title as detail, created_at FROM tasks ORDER BY created_at DESC LIMIT 5)
UNION ALL
(SELECT 'user_registered', email, created_at FROM users ORDER BY created_at DESC LIMIT 5)
UNION ALL
(SELECT 'goal_created', title, created_at FROM goals ORDER BY created_at DESC LIMIT 5)
UNION ALL
(SELECT 'invite_sent', invitee_email, created_at FROM invitations ORDER BY created_at DESC LIMIT 5)
ORDER BY created_at DESC LIMIT 20
`);
return { data: rows };
});
// Error log management
app.delete("/admin/errors/clear", async (req) => {
const { rowCount } = await app.db.query("DELETE FROM error_logs WHERE created_at < NOW() - INTERVAL '7 days'");
return { status: "cleared", deleted: rowCount };
});
}
module.exports = adminRoutes;

View File

@@ -0,0 +1,52 @@
// Task Team — Family Workspace — 2026-03-30
async function familyRoutes(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS families (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
owner_id UUID REFERENCES users(id),
members UUID[] DEFAULT '{}',
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
app.get("/families", async (req) => {
const { user_id } = req.query;
const { rows } = await app.db.query(
"SELECT * FROM families WHERE owner_id=$1 OR $1=ANY(members)", [user_id]);
return { data: rows };
});
app.post("/families", async (req) => {
const { name, owner_id } = req.body;
const { rows } = await app.db.query(
"INSERT INTO families (name, owner_id, members) VALUES ($1,$2,ARRAY[$2]::uuid[]) RETURNING *",
[name, owner_id]);
return { data: rows[0] };
});
app.post("/families/:id/members", async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query(
"UPDATE families SET members=array_append(members,$1::uuid) WHERE id=$2 RETURNING *",
[user_id, req.params.id]);
return { data: rows[0] };
});
app.get("/families/:id/tasks", async (req) => {
const { rows: fam } = await app.db.query("SELECT members FROM families WHERE id=$1", [req.params.id]);
if (!fam.length) throw { statusCode: 404, message: "Family not found" };
const { rows } = await app.db.query(
"SELECT t.*, u.name as creator_name FROM tasks t LEFT JOIN users u ON t.user_id=u.id WHERE t.user_id=ANY($1) ORDER BY t.created_at DESC",
[fam[0].members]);
return { data: rows };
});
app.delete("/families/:id", async (req) => {
await app.db.query("DELETE FROM families WHERE id=$1", [req.params.id]);
return { status: "deleted" };
});
}
module.exports = familyRoutes;

View File

@@ -35,12 +35,16 @@ async function groupRoutes(app) {
});
app.put('/groups/:id', async (req) => {
const { name, color, icon, order_index, time_zones } = req.body;
const { name, color, icon, order_index, time_zones, locations } = req.body;
const { rows } = await app.db.query(
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon),
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), updated_at=NOW()
WHERE id=$6 RETURNING *`,
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id]
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones),
locations=COALESCE($6,locations), updated_at=NOW()
WHERE id=$7 RETURNING *`,
[name, color, icon, order_index,
time_zones ? JSON.stringify(time_zones) : null,
locations ? JSON.stringify(locations) : null,
req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
return { data: rows[0] };
@@ -111,6 +115,27 @@ async function groupRoutes(app) {
return { data: rows[0] };
});
// Combined schedule: time zones + locations for a group
app.get('/groups/:id/schedule', async (req) => {
const { rows } = await app.db.query(
'SELECT id, name, time_zones, locations FROM task_groups WHERE id = $1',
[req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
const g = rows[0];
return {
data: {
group_id: g.id,
group_name: g.name,
time_zones: g.time_zones || [],
locations: g.locations || [],
summary: {
tz_count: (g.time_zones || []).length,
loc_count: (g.locations || []).length
}
}
};
});
}
module.exports = groupRoutes;

View File

@@ -0,0 +1,39 @@
// Task Team — Notification Preferences — 2026-03-30
async function notifPrefRoutes(app) {
app.db.query(`
CREATE TABLE IF NOT EXISTS notification_prefs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
remind_before_minutes INTEGER DEFAULT 15,
remind_on_due BOOLEAN DEFAULT true,
remind_daily BOOLEAN DEFAULT false,
channels JSONB DEFAULT '["push"]',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, task_id)
)
`).catch(() => {});
app.get("/notification-prefs/:taskId", async (req) => {
const { user_id } = req.query;
const { rows } = await app.db.query(
"SELECT * FROM notification_prefs WHERE task_id=$1 AND user_id=$2", [req.params.taskId, user_id]);
return { data: rows[0] || { remind_before_minutes: 15, remind_on_due: true, remind_daily: false, channels: ["push"] } };
});
app.put("/notification-prefs/:taskId", async (req) => {
const { user_id, remind_before_minutes, remind_on_due, remind_daily, channels } = req.body;
const { rows } = await app.db.query(
`INSERT INTO notification_prefs (user_id, task_id, remind_before_minutes, remind_on_due, remind_daily, channels)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (user_id, task_id) DO UPDATE SET
remind_before_minutes=EXCLUDED.remind_before_minutes,
remind_on_due=EXCLUDED.remind_on_due,
remind_daily=EXCLUDED.remind_daily,
channels=EXCLUDED.channels
RETURNING *`,
[user_id, req.params.taskId, remind_before_minutes || 15, remind_on_due !== false, remind_daily || false, JSON.stringify(channels || ["push"])]);
return { data: rows[0] };
});
}
module.exports = notifPrefRoutes;

View File

@@ -0,0 +1,47 @@
// Task Team — Odoo Module Management — 2026-03-30
async function odooModuleRoutes(app) {
const ODOO_ENT = "http://10.10.10.20:8069";
const ODOO_COM = "http://10.10.10.20:8070";
app.get("/odoo/modules", async (req) => {
return { data: {
available: ["task_team_connector"],
location: "/opt/task-team/odoo_modules/",
enterprise_url: ODOO_ENT,
community_url: ODOO_COM
}};
});
app.post("/odoo/modules/install", async (req) => {
const { module_name, server } = req.body;
const url = server === "community" ? ODOO_COM : ODOO_ENT;
// Trigger module install via Odoo JSON-RPC
try {
const authRes = await fetch(url + "/jsonrpc", {
method: "POST", headers: {"Content-Type":"application/json"},
body: JSON.stringify({jsonrpc:"2.0",method:"call",id:1,
params:{service:"common",method:"authenticate",
args:[server==="community"?"odoo_community":"odoo_enterprise","admin","admin",{}]}})
});
const uid = (await authRes.json()).result;
if (!uid) return { status: "error", message: "Odoo auth failed" };
const installRes = await fetch(url + "/jsonrpc", {
method: "POST", headers: {"Content-Type":"application/json"},
body: JSON.stringify({jsonrpc:"2.0",method:"call",id:2,
params:{service:"object",method:"execute_kw",
args:[server==="community"?"odoo_community":"odoo_enterprise",uid,"admin",
"ir.module.module","button_immediate_install",[[["name","=",module_name]]],{}]}})
});
return { status: "ok", result: (await installRes.json()).result };
} catch(e) { return { status: "error", message: e.message }; }
});
app.get("/odoo/status", async (req) => {
let ent = false, com = false;
try { const r = await fetch("http://10.10.10.20:8069/web/login"); ent = r.status === 200; } catch {}
try { const r = await fetch("http://10.10.10.20:8070/web/login"); com = r.status === 200; } catch {}
return { data: { enterprise: ent, community: com } };
});
}
module.exports = odooModuleRoutes;

View File

@@ -0,0 +1,106 @@
// Task Team — Spaced Repetition Engine — 2026-03-30
// SM-2 algorithm for study goals
function calculateNextReview(quality, repetitions, easeFactor, interval) {
// quality: 0-5 (0=complete fail, 5=perfect)
if (quality < 3) {
return { repetitions: 0, interval: 1, easeFactor };
}
let newInterval;
if (repetitions === 0) newInterval = 1;
else if (repetitions === 1) newInterval = 6;
else newInterval = Math.round(interval * easeFactor);
const newEase = Math.max(1.3, easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
return { repetitions: repetitions + 1, interval: newInterval, easeFactor: newEase };
}
async function spacedRepRoutes(app) {
// Create review_items table
await app.db.query(`
CREATE TABLE IF NOT EXISTS review_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
goal_id UUID REFERENCES goals(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT DEFAULT '',
repetitions INTEGER DEFAULT 0,
ease_factor NUMERIC(4,2) DEFAULT 2.5,
interval_days INTEGER DEFAULT 1,
next_review TIMESTAMPTZ DEFAULT NOW(),
last_reviewed TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// Get items due for review
app.get("/review/due", async (req) => {
const { user_id, goal_id, limit } = req.query;
let query = "SELECT * FROM review_items WHERE next_review <= NOW()";
const params = [];
if (user_id) { params.push(user_id); query += ` AND user_id = $${params.length}`; }
if (goal_id) { params.push(goal_id); query += ` AND goal_id = $${params.length}`; }
query += " ORDER BY next_review ASC";
if (limit) { params.push(limit); query += ` LIMIT $${params.length}`; }
const { rows } = await app.db.query(query, params);
return { data: rows, count: rows.length };
});
// Create review item
app.post("/review/items", async (req) => {
const { user_id, goal_id, title, content } = req.body;
const { rows } = await app.db.query(
"INSERT INTO review_items (user_id, goal_id, title, content) VALUES ($1,$2,$3,$4) RETURNING *",
[user_id, goal_id, title, content || ""]
);
return { data: rows[0] };
});
// Submit review (rate quality 0-5)
app.post("/review/items/:id/review", async (req) => {
const { quality } = req.body; // 0-5
if (quality < 0 || quality > 5) throw { statusCode: 400, message: "Quality must be 0-5" };
const { rows } = await app.db.query("SELECT * FROM review_items WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Item not found" };
const item = rows[0];
const result = calculateNextReview(quality, item.repetitions, parseFloat(item.ease_factor), item.interval_days);
const nextReview = new Date(Date.now() + result.interval * 86400000);
const { rows: updated } = await app.db.query(
`UPDATE review_items SET repetitions=$1, ease_factor=$2, interval_days=$3,
next_review=$4, last_reviewed=NOW() WHERE id=$5 RETURNING *`,
[result.repetitions, result.easeFactor, result.interval, nextReview, req.params.id]
);
return { data: updated[0], next_review_in_days: result.interval };
});
// Get single review item
app.get("/review/items/:id", async (req) => {
const { rows } = await app.db.query("SELECT * FROM review_items WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Item not found" };
return { data: rows[0] };
});
// Delete review item
app.delete("/review/items/:id", async (req) => {
const { rowCount } = await app.db.query("DELETE FROM review_items WHERE id = $1", [req.params.id]);
if (!rowCount) throw { statusCode: 404, message: "Item not found" };
return { status: "deleted" };
});
// Stats
app.get("/review/stats", async (req) => {
const { user_id } = req.query;
const { rows } = await app.db.query(`
SELECT count(*) as total,
count(*) FILTER (WHERE next_review <= NOW()) as due,
count(*) FILTER (WHERE last_reviewed > NOW() - INTERVAL '24 hours') as reviewed_today,
avg(ease_factor)::numeric(4,2) as avg_ease
FROM review_items WHERE user_id = $1`, [user_id || "00000000-0000-0000-0000-000000000000"]);
return { data: rows[0] };
});
}
module.exports = spacedRepRoutes;

View File

@@ -0,0 +1,282 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
interface AdminUser {
id: string;
email: string;
name: string;
phone: string | null;
language: string;
auth_provider: string;
created_at: string;
task_count: number;
goal_count: number;
}
interface Analytics {
overview: {
total_users: number;
new_users_7d: number;
total_tasks: number;
completed_tasks: number;
tasks_today: number;
total_goals: number;
accepted_invites: number;
ai_messages: number;
errors_24h: number;
total_projects: number;
};
daily: { day: string; tasks_created: number }[];
}
interface ActivityItem {
type: string;
detail: string;
created_at: string;
}
const API_BASE = typeof window !== "undefined" ? "" : "http://localhost:3000";
async function adminFetch<T>(path: string, opts: { method?: string; token?: string } = {}): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (opts.token) headers["Authorization"] = `Bearer ${opts.token}`;
const res = await fetch(`${API_BASE}${path}`, { method: opts.method || "GET", headers, cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export default function AdminPage() {
const { token } = useAuth();
const router = useRouter();
const [users, setUsers] = useState<AdminUser[]>([]);
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"overview" | "users" | "activity">("overview");
const loadData = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const [usersRes, analyticsRes, activityRes] = await Promise.all([
adminFetch<{ data: AdminUser[] }>("/api/v1/admin/users", { token }),
adminFetch<{ data: Analytics }>("/api/v1/admin/analytics", { token }),
adminFetch<{ data: ActivityItem[] }>("/api/v1/admin/activity", { token }),
]);
setUsers(usersRes.data || []);
setAnalytics(analyticsRes.data || null);
setActivity(activityRes.data || []);
} catch (err) {
console.error("Admin load error:", err);
setError("Failed to load admin data");
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
if (!token) { router.replace("/login"); return; }
loadData();
}, [token, router, loadData]);
async function handleDeleteUser(userId: string) {
if (!token || !confirm("Delete this user and all their data?")) return;
try {
await adminFetch("/api/v1/admin/users/" + userId, { method: "DELETE", token });
loadData();
} catch (err) {
console.error("Delete error:", err);
}
}
async function handleClearErrors() {
if (!token) return;
try {
await adminFetch("/api/v1/admin/errors/clear", { method: "DELETE", token });
loadData();
} catch (err) {
console.error("Clear errors:", err);
}
}
const o = analytics?.overview;
const maxTasks = analytics?.daily ? Math.max(...analytics.daily.map(d => Number(d.tasks_created)), 1) : 1;
function formatDate(d: string) {
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
function typeLabel(t: string) {
const map: Record<string, string> = {
task_created: "New task",
user_registered: "New user",
goal_created: "New goal",
invite_sent: "Invitation",
};
return map[t] || t;
}
function typeColor(t: string) {
const map: Record<string, string> = {
task_created: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
user_registered: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
goal_created: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300",
invite_sent: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
};
return map[t] || "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
}
if (loading) {
return (
<div className="px-4 py-8 text-center">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto" />
<p className="mt-3 text-sm text-muted">Loading admin data...</p>
</div>
);
}
if (error) {
return (
<div className="px-4 py-8 text-center">
<p className="text-red-500 text-sm">{error}</p>
<button onClick={loadData} className="mt-3 text-sm text-blue-500 underline">Retry</button>
</div>
);
}
return (
<div className="px-4 pb-24">
<h1 className="text-xl font-bold mb-4">Admin Dashboard</h1>
{/* Tab bar */}
<div className="flex gap-1 mb-5 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
{(["overview", "users", "activity"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
tab === t
? "bg-white dark:bg-gray-700 shadow-sm text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
{t === "overview" ? "Overview" : t === "users" ? "Users" : "Activity"}
</button>
))}
</div>
{/* Overview tab */}
{tab === "overview" && o && (
<div className="space-y-5">
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{[
{ label: "Users", value: o.total_users, sub: `+${o.new_users_7d} this week`, color: "text-blue-600 dark:text-blue-400" },
{ label: "Tasks", value: o.total_tasks, sub: `${o.completed_tasks} completed`, color: "text-green-600 dark:text-green-400" },
{ label: "Today", value: o.tasks_today, sub: "tasks created", color: "text-purple-600 dark:text-purple-400" },
{ label: "Goals", value: o.total_goals, sub: "total", color: "text-orange-600 dark:text-orange-400" },
{ label: "Projects", value: o.total_projects, sub: "active", color: "text-indigo-600 dark:text-indigo-400" },
{ label: "AI msgs", value: o.ai_messages, sub: "chat responses", color: "text-pink-600 dark:text-pink-400" },
{ label: "Invites", value: o.accepted_invites, sub: "accepted", color: "text-teal-600 dark:text-teal-400" },
{ label: "Errors", value: o.errors_24h, sub: "last 24h", color: o.errors_24h > 0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" },
].map((card) => (
<div key={card.label} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{card.label}</p>
<p className={`text-2xl font-bold mt-1 ${card.color}`}>{card.value}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{card.sub}</p>
</div>
))}
</div>
{/* Daily activity chart */}
{analytics?.daily && analytics.daily.length > 0 && (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
<h3 className="text-sm font-semibold mb-3">Tasks Created (last 7 days)</h3>
<div className="flex items-end gap-2 h-32">
{analytics.daily.map((d) => (
<div key={d.day} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{Number(d.tasks_created)}</span>
<div
className="w-full bg-blue-500 dark:bg-blue-600 rounded-t-md transition-all min-h-[4px]"
style={{ height: `${(Number(d.tasks_created) / maxTasks) * 100}%` }}
/>
<span className="text-[10px] text-gray-400 whitespace-nowrap">
{new Date(d.day).toLocaleDateString("cs-CZ", { day: "numeric", month: "numeric" })}
</span>
</div>
))}
</div>
</div>
)}
{/* Clear errors button */}
{o.errors_24h > 0 && (
<button
onClick={handleClearErrors}
className="text-sm text-red-500 hover:text-red-600 underline"
>
Clear errors older than 7 days
</button>
)}
</div>
)}
{/* Users tab */}
{tab === "users" && (
<div className="space-y-2">
<p className="text-sm text-gray-500 mb-3">{users.length} registered users</p>
{users.map((u) => (
<div key={u.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0">
{(u.name || u.email || "?").charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{u.name || "No name"}</p>
<p className="text-xs text-gray-500 truncate">{u.email}</p>
<div className="flex gap-3 mt-1">
<span className="text-xs text-gray-400">{u.task_count} tasks</span>
<span className="text-xs text-gray-400">{u.goal_count} goals</span>
<span className="text-xs text-gray-400">{u.auth_provider || "email"}</span>
<span className="text-xs text-gray-400">{u.language || "cs"}</span>
</div>
</div>
<button
onClick={() => handleDeleteUser(u.id)}
className="p-2 text-red-400 hover:text-red-600 transition-colors flex-shrink-0"
title="Delete user"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Activity tab */}
{tab === "activity" && (
<div className="space-y-2">
{activity.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">No recent activity</p>
) : (
activity.map((a, i) => (
<div key={i} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-3 flex items-center gap-3">
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap ${typeColor(a.type)}`}>
{typeLabel(a.type)}
</span>
<span className="text-sm truncate flex-1">{a.detail}</span>
<span className="text-xs text-gray-400 whitespace-nowrap flex-shrink-0">{formatDate(a.created_at)}</span>
</div>
))
)}
</div>
)}
</div>
);
}

View File

@@ -63,25 +63,21 @@ export default function CalendarPage() {
extendedProps: { status: tk.status, group: tk.group_name },
}));
// Build background events from unique groups
const groupColors = new Map<string, string>();
tasks.forEach(tk => {
if (tk.group_name && tk.group_color) {
groupColors.set(tk.group_name, tk.group_color);
}
});
if (!token) return null;
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
<div className="pb-24 sm:pb-8 px-4 sm:px-0">
{/* Compact single-row header */}
<div className="flex items-center justify-between gap-2 mb-3">
<h1 className="text-xl font-bold dark:text-white truncate">{t('calendar.title')}</h1>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
<div className="bg-white dark:bg-gray-800 rounded-xl p-2 sm:p-4 shadow calendar-compact">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek"

View File

@@ -254,20 +254,39 @@ main {
}
/* ============================
FullCalendar Mobile Layout Fix
FullCalendar Compact Layout
============================ */
/* Base toolbar styles - compact */
/* Single-row toolbar: date + nav + view switcher all in one line */
.fc .fc-toolbar {
flex-wrap: wrap;
gap: 4px 8px;
row-gap: 6px;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
font-size: 14px;
padding: 0;
margin-bottom: 8px !important;
}
/* Title: prevent vertical text wrapping */
/* Each toolbar chunk inline */
.fc .fc-toolbar-chunk {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* Center chunk (title) takes remaining space, truncates */
.fc .fc-toolbar-chunk:nth-child(2) {
flex: 1;
min-width: 0;
justify-content: center;
}
/* Title: compact, truncates */
.fc .fc-toolbar-title {
font-size: 18px !important;
font-size: 16px !important;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -275,48 +294,17 @@ main {
/* Compact buttons */
.fc .fc-button {
padding: 4px 10px !important;
font-size: 13px !important;
line-height: 1.4 !important;
padding: 4px 8px !important;
font-size: 12px !important;
line-height: 1.3 !important;
min-height: 32px;
border-radius: 6px;
}
/* Button group: no extra gaps */
/* Button group: pill shape */
.fc .fc-button-group {
gap: 0;
}
/* Mobile breakpoint: stack toolbar rows */
@media (max-width: 640px) {
.fc .fc-toolbar {
flex-direction: column;
align-items: center;
gap: 6px;
}
.fc .fc-toolbar-chunk {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
width: 100%;
}
/* Title on its own row, centered, smaller */
.fc .fc-toolbar-title {
font-size: 15px !important;
text-align: center;
width: 100%;
order: -1;
}
/* Smaller buttons on mobile */
.fc .fc-button {
padding: 3px 8px !important;
font-size: 12px !important;
min-height: 32px !important;
}
/* View switcher as pill buttons */
.fc .fc-button-group .fc-button {
border-radius: 0 !important;
}
@@ -327,6 +315,30 @@ main {
border-radius: 0 9999px 9999px 0 !important;
}
/* Mobile: tighten further but keep single row */
@media (max-width: 640px) {
.fc .fc-toolbar {
gap: 4px;
}
.fc .fc-toolbar-title {
font-size: 13px !important;
}
.fc .fc-button {
padding: 3px 6px !important;
font-size: 11px !important;
min-height: 32px !important;
}
/* Hide text labels on smallest screens - show abbreviated */
.fc .fc-dayGridMonth-button,
.fc .fc-timeGridWeek-button,
.fc .fc-timeGridDay-button {
font-size: 10px !important;
padding: 3px 5px !important;
}
/* Reduce page padding */
.fc {
font-size: 12px;
@@ -339,20 +351,72 @@ main {
/* Day header smaller */
.fc .fc-col-header-cell-cushion {
font-size: 12px;
padding: 4px 2px;
font-size: 11px;
padding: 3px 2px;
}
}
/* Small mobile (< 400px) - even tighter */
@media (max-width: 400px) {
.fc .fc-toolbar-title {
font-size: 13px !important;
font-size: 12px !important;
}
.fc .fc-button {
padding: 2px 6px !important;
font-size: 11px !important;
padding: 2px 4px !important;
font-size: 10px !important;
min-height: 28px !important;
}
.fc .fc-today-button {
display: none;
}
}
/* Status dot pulse animation for in_progress tasks */
@keyframes statusPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-dot-active {
animation: statusPulse 2s ease-in-out infinite;
}
/* ============================
Mobile Responsive Compact Headers (#9)
============================ */
/* Ensure no horizontal overflow on any page */
@media (max-width: 640px) {
main {
overflow-x: hidden;
}
/* Page action bars: single row, no wrap */
.flex.items-center.justify-between {
flex-wrap: nowrap;
overflow: hidden;
}
/* Touch targets minimum 44px on mobile */
button,
a[role='button'],
[role='button'] {
min-height: 44px;
min-width: 44px;
}
/* Exception: status pills and small inline elements */
.scrollbar-hide button,
.fc button {
min-width: auto;
min-height: 28px;
}
/* Compact text on mobile */
h1 {
font-size: 1.125rem;
line-height: 1.4;
}
}

View File

@@ -18,6 +18,9 @@ import {
GoalReport,
Group,
} from "@/lib/api";
import PageActionBar from "@/components/features/PageActionBar";
import GoalActionButtons from "@/components/features/GoalActionButtons";
import IconButton from "@/components/features/IconButton";
export default function GoalsPage() {
const { token } = useAuth();
@@ -33,7 +36,6 @@ export default function GoalsPage() {
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("");
@@ -172,18 +174,14 @@ export default function GoalsPage() {
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">{t("goals.title")}</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 ? t("tasks.form.cancel") : `+ ${t("goals.add")}`}
</button>
</div>
<PageActionBar
title={t("goals.title")}
showAdd
onToggleAdd={() => setShowForm(!showForm)}
addOpen={showForm}
t={t}
/>
{/* 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}
@@ -191,7 +189,6 @@ export default function GoalsPage() {
</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>
@@ -237,7 +234,6 @@ export default function GoalsPage() {
</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" />
@@ -278,7 +274,6 @@ export default function GoalsPage() {
{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)}`}
@@ -294,20 +289,23 @@ export default function GoalsPage() {
{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>
<div className="flex-1 min-w-0">
<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)} | {t("goals.progress")}: {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"
>
<IconButton
icon={
<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>
}
label={t("tasks.close")}
onClick={() => setSelectedGoal(null)}
variant="default"
size="md"
/>
</div>
{/* Progress slider */}
@@ -325,47 +323,15 @@ export default function GoalsPage() {
/>
</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" />
{t("common.loading")}
</>
) : (
<>
<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>
{t("goals.plan")}
</>
)}
</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" />
{t("common.loading")}
</>
) : (
<>
<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>
{t("goals.report")}
</>
)}
</button>
</div>
{/* AI Action buttons - icon only */}
<GoalActionButtons
onPlan={() => handleGeneratePlan(selectedGoal.id)}
onReport={() => handleGetReport(selectedGoal.id)}
onDelete={() => handleDelete(selectedGoal.id)}
planLoading={aiLoading === "plan"}
reportLoading={aiLoading === "report"}
t={t}
/>
{/* Plan result */}
{planResult && (
@@ -468,14 +434,6 @@ export default function GoalsPage() {
</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]"
>
{t("tasks.delete")}
</button>
</div>
)}
</div>

View File

@@ -10,6 +10,8 @@ import {
deleteProject,
Project,
} from "@/lib/api";
import PageActionBar from "@/components/features/PageActionBar";
import DeleteIconButton from "@/components/features/DeleteIconButton";
export default function ProjectsPage() {
const { token, user } = useAuth();
@@ -20,7 +22,6 @@ export default function ProjectsPage() {
const [showForm, setShowForm] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [formName, setFormName] = useState("");
const [formDesc, setFormDesc] = useState("");
const [formColor, setFormColor] = useState("#3B82F6");
@@ -96,18 +97,14 @@ export default function ProjectsPage() {
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">{t("nav.projects")}</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 ? t("tasks.form.cancel") : `+ ${t("projects.add")}`}
</button>
</div>
<PageActionBar
title={t("nav.projects")}
showAdd
onToggleAdd={() => setShowForm(!showForm)}
addOpen={showForm}
t={t}
/>
{/* 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}
@@ -115,7 +112,6 @@ export default function ProjectsPage() {
</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>
@@ -179,7 +175,6 @@ export default function ProjectsPage() {
</form>
)}
{/* Projects 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" />
@@ -219,15 +214,11 @@ export default function ProjectsPage() {
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
<button
<DeleteIconButton
onClick={() => handleDelete(project.id)}
className="p-2 text-gray-400 hover:text-red-500 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
title={t("tasks.delete")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
label={t("tasks.delete")}
size="sm"
/>
</div>
</div>
</div>

View File

@@ -6,6 +6,16 @@ import { useAuth } from "@/lib/auth";
import { useTheme } from "@/components/ThemeProvider";
import { useTranslation, LOCALES } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
import type { Group } from "@/lib/api";
interface GroupSetting {
from: string;
to: string;
days: number[];
locationName: string;
gps: string;
radius: number;
}
export default function SettingsPage() {
const { token, user, logout } = useAuth();
@@ -19,6 +29,10 @@ export default function SettingsPage() {
dailySummary: false,
});
const [saved, setSaved] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [savedGroup, setSavedGroup] = useState<string | null>(null);
useEffect(() => {
if (!token) {
@@ -37,6 +51,84 @@ export default function SettingsPage() {
}
}, [token, router]);
useEffect(() => {
if (!token) return;
fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(res => {
const data: Group[] = res.data || [];
setGroups(data);
const settings: Record<string, GroupSetting> = {};
for (const g of data) {
const tz = g.time_zones?.[0];
const loc = g.locations?.[0];
settings[g.id] = {
from: tz?.from || "",
to: tz?.to || "",
days: tz?.days || [],
locationName: loc?.name || "",
gps: (loc?.lat != null && loc?.lng != null) ? `${loc.lat}, ${loc.lng}` : "",
radius: loc?.radius_m || 200,
};
}
setGroupSettings(settings);
})
.catch(() => {});
}, [token]);
function toggleGroup(id: string) {
setExpandedGroup(prev => prev === id ? null : id);
}
function updateGroupSetting(groupId: string, key: keyof GroupSetting, value: string | number | number[]) {
setGroupSettings(prev => ({
...prev,
[groupId]: { ...prev[groupId], [key]: value },
}));
}
function toggleDay(groupId: string, day: number) {
const current = groupSettings[groupId]?.days || [];
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
updateGroupSetting(groupId, "days", next);
}
function getCurrentLocation(groupId: string) {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(pos => {
const gps = `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`;
updateGroupSetting(groupId, "gps", gps);
});
}
async function saveGroupSettings(groupId: string) {
const s = groupSettings[groupId] || {} as GroupSetting;
const timeZones = (s.from && s.to) ? [{
days: s.days?.length ? s.days : [0, 1, 2, 3, 4, 5, 6],
from: s.from,
to: s.to,
}] : [];
const gpsParts = (s.gps || "").split(",").map(x => parseFloat(x.trim()));
const lat = gpsParts[0] || null;
const lng = gpsParts[1] || null;
const locations = s.locationName ? [{
name: s.locationName,
lat: isNaN(lat as number) ? null : lat,
lng: isNaN(lng as number) ? null : lng,
radius_m: Number(s.radius) || 200,
}] : [];
await fetch(`/api/v1/groups/${groupId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ time_zones: timeZones, locations }),
});
setSavedGroup(groupId);
setTimeout(() => setSavedGroup(null), 2000);
}
function handleSave() {
if (typeof window !== "undefined") {
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
@@ -193,6 +285,113 @@ export default function SettingsPage() {
</div>
</div>
{/* Groups settings */}
{groups.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 12, textTransform: "uppercase", letterSpacing: 1, fontWeight: 600 }}>
Skupiny
</div>
{groups.map(group => (
<div key={group.id} style={{
background: "#13131A", border: `1px solid #2A2A3A`,
borderLeft: `3px solid ${group.color || "#4F46E5"}`,
borderRadius: 12, marginBottom: 8, overflow: "hidden",
}}>
<div onClick={() => toggleGroup(group.id)}
style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<span style={{ fontSize: 18 }}>{group.icon || "📁"}</span>
<span style={{ flex: 1, fontWeight: 500, fontSize: 14, color: "#F0F0F5" }}>
{group.display_name || group.name}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>
{group.time_zones?.[0] ? `${group.time_zones[0].from}${group.time_zones[0].to}` : ""}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>{expandedGroup === group.id ? "▲" : "▼"}</span>
</div>
{expandedGroup === group.id && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #2A2A3A" }}>
{/* CAS AKTIVITY */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Čas aktivity (volitelné)
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="time" value={groupSettings[group.id]?.from || ""}
onChange={e => updateGroupSetting(group.id, "from", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<span style={{ color: "#6B6B85" }}></span>
<input type="time" value={groupSettings[group.id]?.to || ""}
onChange={e => updateGroupSetting(group.id, "to", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
</div>
{/* DNY V TYDNU */}
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => {
const active = (groupSettings[group.id]?.days || []).includes(i);
return (
<button key={i} onClick={() => toggleDay(group.id, i)} style={{
flex: 1, padding: "6px 0", borderRadius: 6, fontSize: 11,
border: `1px solid ${active ? (group.color || "#4F46E5") : "#2A2A3A"}`,
background: active ? `${group.color || "#4F46E5"}20` : "transparent",
color: active ? (group.color || "#4F46E5") : "#6B6B85",
cursor: "pointer",
}}>{d}</button>
);
})}
</div>
</div>
{/* GPS MISTO */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Místo výkonu (volitelné)
</div>
<input
placeholder="Název místa (např. Synagoga, Kancelář...)"
value={groupSettings[group.id]?.locationName || ""}
onChange={e => updateGroupSetting(group.id, "locationName", e.target.value)}
style={{ width: "100%", padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14, marginBottom: 8, boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<input
placeholder="GPS souřadnice (lat, lng)"
value={groupSettings[group.id]?.gps || ""}
onChange={e => updateGroupSetting(group.id, "gps", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<button onClick={() => getCurrentLocation(group.id)}
style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #1D4ED8", background: "#1D4ED820", color: "#60A5FA", cursor: "pointer", fontSize: 12, whiteSpace: "nowrap" }}>
Moje GPS
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
<span style={{ fontSize: 12, color: "#6B6B85" }}>Polomer:</span>
<input type="range" min="50" max="1000" step="50"
value={groupSettings[group.id]?.radius || 200}
onChange={e => updateGroupSetting(group.id, "radius", Number(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: "#9999AA", minWidth: 50 }}>
{groupSettings[group.id]?.radius || 200}m
</span>
</div>
</div>
{/* ULOZIT */}
<button onClick={() => saveGroupSettings(group.id)}
style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 10, background: savedGroup === group.id ? "#16A34A" : "#1D4ED8", color: "white", border: "none", cursor: "pointer", fontSize: 14, fontWeight: 500, transition: "background 0.2s" }}>
{savedGroup === group.id ? "Uloženo ✓" : "Uložit nastavení skupiny"}
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Save button */}
<button
onClick={handleSave}

View File

@@ -17,6 +17,9 @@ import {
sendCollabRequest,
searchUsers,
} from "@/lib/api";
import CollabBackButton from "@/components/features/CollabBackButton";
import CollabActionButtons from "@/components/features/CollabActionButtons";
import IconButton from "@/components/features/IconButton";
interface UserResult {
id: string;
@@ -130,7 +133,6 @@ export default function CollaboratePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Forms
const [showAssignSearch, setShowAssignSearch] = useState(false);
const [showTransferSearch, setShowTransferSearch] = useState(false);
const [transferMessage, setTransferMessage] = useState("");
@@ -152,19 +154,15 @@ export default function CollaboratePage() {
setSubtasks(subtasksData.data || []);
setHistory(historyData.data || []);
// Load assignee details
const assignedIds: string[] = taskData.assigned_to || [];
if (assignedIds.length > 0) {
try {
const usersRes = await searchUsers(token, "");
// Filter by IDs — the workload endpoint returns all users, filter client-side
// For now just show what we can from collaboration history
const knownUsers = new Map<string, UserResult>();
(historyData.data || []).forEach((h: CollabRequest) => {
if (h.from_user_id && h.from_name) knownUsers.set(h.from_user_id, { id: h.from_user_id, name: h.from_name, email: "", avatar_url: null });
if (h.to_user_id && h.to_name) knownUsers.set(h.to_user_id, { id: h.to_user_id, name: h.to_name, email: "", avatar_url: null });
});
// Merge with any search results
(usersRes.data || []).forEach((u: UserResult) => knownUsers.set(u.id, u));
setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }));
} catch {
@@ -316,18 +314,13 @@ export default function CollaboratePage() {
};
return (
<div className="max-w-lg mx-auto space-y-4 pb-20">
<div className="max-w-lg mx-auto space-y-4 pb-20 px-4 sm:px-0">
{/* Header */}
<div className="flex items-center gap-2">
<button
<CollabBackButton
onClick={() => router.push(`/tasks/${id}`)}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t("common.back")}
</button>
label={t("common.back")}
/>
<h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1>
</div>
@@ -367,47 +360,20 @@ export default function CollaboratePage() {
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
{/* Action buttons - icon only with tooltips */}
<CollabActionButtons
onAssign={() => {
setShowAssignSearch(!showAssignSearch);
setShowTransferSearch(false);
}}
disabled={actionLoading}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
{t("collab.assign")}
</button>
<button
onClick={() => {
onTransfer={() => {
setShowTransferSearch(!showTransferSearch);
setShowAssignSearch(false);
}}
onClaim={handleClaim}
disabled={actionLoading}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{t("collab.transfer")}
</button>
<button
onClick={handleClaim}
disabled={actionLoading}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0 0V14m0 0h2.5M7 14H4.5m4.5 0a6 6 0 1012 0 6 6 0 00-12 0z" />
</svg>
{t("collab.claim")}
</button>
</div>
t={t}
/>
{/* Assign search dropdown */}
{showAssignSearch && (
@@ -452,7 +418,6 @@ export default function CollaboratePage() {
)}
</div>
{/* Progress bar */}
{subtasksTotal > 0 && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
<div
@@ -462,10 +427,9 @@ export default function CollaboratePage() {
</div>
)}
{/* Subtask list */}
<div className="space-y-2 mb-3">
{subtasks.map((sub) => {
const isDone = sub.status === "done" || sub.status === "completed";
const subDone = sub.status === "done" || sub.status === "completed";
return (
<div
key={sub.id}
@@ -474,18 +438,18 @@ export default function CollaboratePage() {
<button
onClick={() => handleToggleSubtask(sub)}
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
isDone
subDone
? "bg-green-500 border-green-500 text-white"
: "border-gray-300 dark:border-gray-600 hover:border-green-400"
}`}
>
{isDone && (
{subDone && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={`flex-1 text-sm ${isDone ? "line-through text-gray-400" : ""}`}>
<span className={`flex-1 text-sm ${subDone ? "line-through text-gray-400" : ""}`}>
{sub.title}
</span>
{sub.assignee_name && (
@@ -495,7 +459,8 @@ export default function CollaboratePage() {
)}
<button
onClick={() => handleDeleteSubtask(sub.id)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity"
title={t("tasks.delete")}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity p-1 rounded"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -510,7 +475,6 @@ export default function CollaboratePage() {
<p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
)}
{/* Add subtask form */}
{showSubtaskForm ? (
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<input
@@ -543,35 +507,47 @@ export default function CollaboratePage() {
/>
)}
<div className="flex gap-2">
<button
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
}
label={t("collab.addBtn")}
onClick={handleAddSubtask}
disabled={!newSubtaskTitle.trim() || actionLoading}
className="px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
{t("collab.addBtn")}
</button>
<button
variant="primary"
size="md"
/>
<IconButton
icon={
<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>
}
label={t("tasks.form.cancel")}
onClick={() => {
setShowSubtaskForm(false);
setNewSubtaskTitle("");
setSubtaskAssignee(null);
}}
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400"
>
{t("tasks.form.cancel")}
</button>
variant="default"
size="md"
/>
</div>
</div>
) : (
<button
onClick={() => setShowSubtaskForm(true)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
{t("collab.addSubtask")}
</button>
}
label={t("collab.addSubtask")}
onClick={() => setShowSubtaskForm(true)}
variant="primary"
size="md"
/>
)}
</div>

View File

@@ -18,6 +18,8 @@ import {
import TaskForm from "@/components/TaskForm";
import InviteModal from "@/components/InviteModal";
import StatusBadge from "@/components/StatusBadge";
import TaskDetailActions from "@/components/features/TaskDetailActions";
import InlineEditField from "@/components/features/InlineEditField";
function isDone(status: string): boolean {
return status === "done" || status === "completed";
@@ -70,7 +72,6 @@ export default function TaskDetailPage() {
setTask(taskData);
setGroups(groupsData.data || []);
setSubtasks(subtasksData.data || []);
// Load assignee names
const assigned: string[] = taskData.assigned_to || [];
if (assigned.length > 0) {
try {
@@ -106,6 +107,16 @@ export default function TaskDetailPage() {
loadTask();
}
async function handleInlineUpdate(field: string, value: string) {
if (!token || !id) return;
try {
await updateTask(token, id, { [field]: value });
loadTask();
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
}
}
async function handleDelete() {
if (!token || !id) return;
if (!confirm(t("tasks.confirmDelete"))) return;
@@ -183,90 +194,46 @@ export default function TaskDetailPage() {
const taskDone = isDone(task.status);
return (
<div className="max-w-lg mx-auto space-y-4">
{/* Action bar - all buttons in one compact row */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => router.push("/tasks")}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t("common.back")}
</button>
<div className="max-w-lg mx-auto space-y-4 px-4 sm:px-0">
{/* Action bar - all icon buttons in one compact row */}
<TaskDetailActions
taskDone={taskDone}
deleting={deleting}
onBack={() => router.push("/tasks")}
onEdit={() => setEditing(true)}
onDelete={handleDelete}
onToggleStatus={() => handleQuickStatus(taskDone ? "pending" : "done")}
onInvite={() => setShowInvite(true)}
onCollaborate={() => router.push(`/tasks/${id}/collaborate`)}
t={t}
/>
<div className="flex-1" />
{!taskDone && (
<button
onClick={() => handleQuickStatus("done")}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{t("tasks.markDone")}
</button>
)}
{taskDone && (
<button
onClick={() => handleQuickStatus("pending")}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{t("tasks.reopen")}
</button>
)}
<button
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{t("tasks.edit")}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm 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 rounded-lg transition-colors disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{deleting ? t("tasks.deleting") : t("tasks.delete")}
</button>
</div>
{/* Task detail card */}
{/* Task detail card with inline editing */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-start gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{task.group_icon && (
<span className="text-2xl">{task.group_icon}</span>
<span className="text-2xl flex-shrink-0">{task.group_icon}</span>
)}
<h1
className={`text-xl font-bold ${
taskDone ? "line-through text-muted" : ""
}`}
>
{task.title}
</h1>
<InlineEditField
value={task.title}
onSave={(val) => handleInlineUpdate("title", val)}
className={`text-xl font-bold ${taskDone ? "line-through text-muted" : ""}`}
/>
</div>
<StatusBadge status={task.status} size="md" />
</div>
{task.description && (
<p className="text-muted mb-4 whitespace-pre-wrap leading-relaxed">
{task.description}
</p>
)}
<div className="mb-4">
<InlineEditField
value={task.description || ""}
onSave={(val) => handleInlineUpdate("description", val)}
as="textarea"
multiline
placeholder={t("tasks.form.descPlaceholder")}
className="text-muted whitespace-pre-wrap leading-relaxed"
/>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
@@ -320,15 +287,6 @@ export default function TaskDetailPage() {
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("collab.collaboration")}
</h2>
<button
onClick={() => router.push(`/tasks/${id}/collaborate`)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{t("collab.collaboration")}
</button>
</div>
{/* Assigned users */}

View File

@@ -6,21 +6,33 @@ import { useAuth } from "@/lib/auth";
import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
import TaskCard from "@/components/TaskCard";
import GroupSelector from "@/components/GroupSelector";
import TaskModal from "@/components/TaskModal";
import { useSwipeable } from "react-swipeable";
type StatusFilter = "all" | "pending" | "in_progress" | "done" | "cancelled";
const STATUS_VALUES = ["pending", "in_progress", "done", "cancelled"] as const;
type StatusValue = (typeof STATUS_VALUES)[number];
function statusColor(s: StatusValue | null): string {
switch (s) {
case "pending": return "#FBBF24";
case "in_progress": return "#60A5FA";
case "done": return "#34D399";
case "cancelled": return "#9CA3AF";
default: return "#7A7A9A";
}
}
export default function TasksPage() {
const { token } = useAuth();
const { token, user } = useAuth();
const { t } = useTranslation();
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [selectedStatus, setSelectedStatus] = useState<StatusValue | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [showForm, setShowForm] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null);
const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | null>(null);
@@ -34,13 +46,18 @@ export default function TasksPage() {
return groupOrder.indexOf(selectedGroup);
}, [groupOrder, selectedGroup]);
const selectedGroupObj = useMemo(
() => (selectedGroup ? groups.find((g) => g.id === selectedGroup) ?? null : null),
[selectedGroup, groups]
);
const loadData = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const params: Record<string, string> = {};
if (selectedGroup) params.group_id = selectedGroup;
if (statusFilter !== "all") params.status = statusFilter;
if (selectedStatus) params.status = selectedStatus;
const [tasksRes, groupsRes] = await Promise.all([
getTasks(token, Object.keys(params).length > 0 ? params : undefined),
@@ -53,7 +70,7 @@ export default function TasksPage() {
} finally {
setLoading(false);
}
}, [token, selectedGroup, statusFilter]);
}, [token, selectedGroup, selectedStatus]);
useEffect(() => {
if (!token) {
@@ -118,46 +135,141 @@ export default function TasksPage() {
}
}
const statusOptions: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("tasks.all") },
{ value: "pending", label: t("tasks.status.pending") },
{ value: "in_progress", label: t("tasks.status.in_progress") },
{ value: "done", label: t("tasks.status.done") },
{ value: "cancelled", label: t("tasks.status.cancelled") },
];
if (!token) return null;
const userInitial = (user?.name || user?.email || "?").charAt(0).toUpperCase();
return (
<div className="space-y-2 pb-24 sm:pb-8 px-4 sm:px-0">
{/* Group dropdown + Status pills — single compact row */}
<div className="flex items-center gap-3 flex-nowrap">
<div className="flex-shrink-0">
<GroupSelector
groups={groups}
selected={selectedGroup}
onSelect={setSelectedGroup}
/>
</div>
<div className="flex gap-1 overflow-x-auto scrollbar-hide flex-1 min-w-0">
{statusOptions.map((opt) => (
<div className="pb-24 sm:pb-8">
{/* Single-row sticky filter header — group dropdown left, status pills right */}
<div style={{
display: "flex", alignItems: "center",
padding: "0 8px",
position: "sticky", top: 40, zIndex: 40,
height: 40, maxHeight: 40,
background: "var(--background, #fff)",
borderBottom: "1px solid var(--border, #e5e7eb)",
flexWrap: "nowrap", overflow: "hidden",
}}>
{/* Groups dropdown — compact, left side */}
<div style={{ position: "relative", flexShrink: 0 }}>
<button
key={opt.value}
onClick={() => setStatusFilter(opt.value)}
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-all ${
statusFilter === opt.value
? "bg-gray-800 text-white dark:bg-white dark:text-gray-900"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
onClick={() => { setGroupOpen(o => !o); }}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "4px 10px", borderRadius: 16, height: 32,
border: `1.5px solid ${selectedGroupObj?.color || "var(--border, #3A3A4A)"}`,
background: selectedGroupObj ? selectedGroupObj.color + "18" : "transparent",
color: selectedGroupObj?.color || "var(--foreground, #374151)",
fontSize: 12, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap",
}}
>
{opt.label}
{selectedGroupObj
? `${selectedGroupObj.icon ?? ""} ${(selectedGroupObj as any).name}`
: t("tasks.all")}
<span style={{ fontSize: 8, opacity: 0.5, marginLeft: 1 }}>{groupOpen ? "\u25b2" : "\u25bc"}</span>
</button>
{groupOpen && (
<>
<div onClick={() => setGroupOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 150 }} />
<div style={{
position: "absolute", top: "110%", left: 0, zIndex: 200,
background: "var(--popover, #fff)", border: "1px solid var(--border, #e5e7eb)",
borderRadius: 12, padding: "4px 0", minWidth: 180,
boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
}}>
<button
onClick={() => { setSelectedGroup(null); setGroupOpen(false); }}
style={{
width: "100%", padding: "9px 14px",
background: !selectedGroup ? "rgba(29,78,216,0.08)" : "transparent",
border: "none", borderLeft: `3px solid ${!selectedGroup ? "#1D4ED8" : "transparent"}`,
color: !selectedGroup ? "#2563EB" : "var(--foreground, #374151)",
textAlign: "left", fontSize: 13, cursor: "pointer",
}}
>
{t("tasks.all")}
</button>
{groups.map(g => (
<button
key={g.id}
onClick={() => { setSelectedGroup(g.id); setGroupOpen(false); }}
style={{
width: "100%", padding: "9px 14px",
background: selectedGroup === g.id ? g.color + "18" : "transparent",
border: "none", borderLeft: `3px solid ${selectedGroup === g.id ? g.color : "transparent"}`,
color: selectedGroup === g.id ? g.color : "var(--foreground, #374151)",
textAlign: "left", fontSize: 13, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
}}
>
<span>{g.icon}</span>
{g.name}
</button>
))}
</div>
</>
)}
</div>
{/* Status pills — horizontal scroll, right side */}
<div style={{
display: "flex", alignItems: "center", gap: 4,
flex: 1, overflow: "hidden", marginLeft: 6,
justifyContent: "flex-end",
}}
className="scrollbar-hide"
>
<div style={{
display: "flex", alignItems: "center", gap: 3,
overflowX: "auto", flexShrink: 1,
}}
className="scrollbar-hide"
>
{/* "All" pill */}
<button
onClick={() => setSelectedStatus(null)}
style={{
padding: "3px 10px", borderRadius: 14, height: 28,
border: "none", cursor: "pointer", whiteSpace: "nowrap",
fontSize: 11, fontWeight: selectedStatus === null ? 700 : 500,
background: selectedStatus === null ? "rgba(29,78,216,0.12)" : "transparent",
color: selectedStatus === null ? "#2563EB" : "var(--muted, #6B7280)",
transition: "all 0.15s ease",
flexShrink: 0,
}}
>
{t("tasks.all")}
</button>
{STATUS_VALUES.map(s => (
<button
key={s}
onClick={() => setSelectedStatus(selectedStatus === s ? null : s)}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "3px 10px", borderRadius: 14, height: 28,
border: "none", cursor: "pointer", whiteSpace: "nowrap",
fontSize: 11, fontWeight: selectedStatus === s ? 700 : 500,
background: selectedStatus === s ? statusColor(s) + "20" : "transparent",
color: selectedStatus === s ? statusColor(s) : "var(--muted, #6B7280)",
transition: "all 0.15s ease",
flexShrink: 0,
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: statusColor(s), flexShrink: 0,
}} />
{t(`tasks.status.${s}`)}
</button>
))}
</div>
</div>
</div>
{/* Swipeable task list area */}
<div {...swipeHandlers} className="relative min-h-[200px]">
<div {...swipeHandlers} className="relative min-h-[200px] px-4 sm:px-0 pt-2">
{/* Swipe overlay */}
{swipeOverlay && (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect, useState } from 'react';
export default function WidgetPage() {
const [tasks, setTasks] = useState<any[]>([]);
useEffect(() => {
const token = localStorage.getItem('taskteam_token');
if (token) {
fetch('/api/v1/tasks?limit=5&status=pending', { headers: { Authorization: 'Bearer ' + token } })
.then(r => r.json()).then(d => setTasks(d.data || []));
}
}, []);
return (
<div style={{ padding: 8, background: '#0F172A', minHeight: '100vh', color: 'white', fontSize: 14 }}>
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>Task Team</div>
{tasks.map(t => (
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #1E293B', display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: t.status === 'in_progress' ? '#F59E0B' : '#6B7280', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
</div>
))}
{!tasks.length && <div style={{ opacity: 0.5 }}>Zadne ukoly</div>}
</div>
);
}

View File

@@ -1,205 +1,7 @@
"use client";
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";
import CompactHeader from "./features/CompactHeader";
export default function Header() {
const { user, logout, token } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
const router = useRouter();
const [drawerOpen, setDrawerOpen] = useState(false);
function handleLogout() {
logout();
router.push("/login");
setDrawerOpen(false);
}
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
// Close drawer on escape
useEffect(() => {
if (!drawerOpen) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") closeDrawer();
}
document.addEventListener("keydown", handleKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKey);
document.body.style.overflow = "";
};
}, [drawerOpen, closeDrawer]);
return (
<>
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
<div className="max-w-4xl mx-auto px-4 h-11 flex items-center justify-end gap-2">
{/* Right side only: avatar (no name text) + hamburger */}
{token && user && (
<button
onClick={openDrawer}
className="w-7 h-7 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 hover:ring-2 hover:ring-blue-400 transition-all flex-shrink-0"
aria-label={t("common.menu")}
>
{(user.name || user.email || "?").charAt(0).toUpperCase()}
</button>
)}
<button
onClick={openDrawer}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center flex-shrink-0"
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" />
</svg>
</button>
</div>
</header>
{/* Slide-out drawer */}
{drawerOpen && (
<div className="fixed inset-0 z-[60]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fadeIn"
onClick={closeDrawer}
/>
{/* Drawer panel */}
<div className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-gray-900 shadow-2xl animate-slideInRight flex flex-col">
{/* 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">{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={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" />
</svg>
</button>
</div>
{/* User info - full name + email */}
{token && user && (
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold">
{(user.name || user.email || "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<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>
</div>
)}
{/* Menu items */}
<div className="p-2 space-y-1 flex-1 overflow-y-auto">
<Link
href="/tasks"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<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">{t("nav.tasks")}</span>
</Link>
<Link
href="/calendar"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<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">{t("nav.calendar")}</span>
</Link>
<Link
href="/chat"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<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">{t("nav.chat")}</span>
</Link>
<Link
href="/settings"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<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="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">{t("nav.settings")}</span>
</Link>
{/* Install link */}
<Link
href="/settings#install"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="text-sm font-medium">{t("settings.install") || "Instalace"}</span>
</Link>
{/* Theme toggle */}
<button
onClick={() => { toggleTheme(); }}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full text-left min-h-[48px]"
>
{theme === "dark" ? (
<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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
<span className="text-sm font-medium">
{theme === "dark" ? t("settings.light") : t("settings.dark")}
</span>
</button>
</div>
{/* Logout icon at bottom */}
{token && (
<div className="p-4 border-t border-gray-200 dark:border-gray-800 safe-area-bottom">
<button
onClick={handleLogout}
title={t("auth.logout")}
className="p-2 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={t("auth.logout")}
>
<svg className="w-5 h-5 text-red-500" 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>
</button>
</div>
)}
</div>
</div>
)}
</>
);
return <CompactHeader />;
}

View File

@@ -3,49 +3,76 @@
import { useState, useRef } from "react";
import { Task } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
import StatusBadge from "./StatusBadge";
import Link from "next/link";
import { useSwipeable } from "react-swipeable";
interface TaskCardProps {
task: Task;
onComplete?: (taskId: string) => void;
onAssign?: (taskId: string) => void;
}
const PRIORITY_COLORS: Record<string, string> = {
urgent: "#ef4444",
high: "#f97316",
medium: "#eab308",
low: "#22c55e",
};
function isDone(status: string): boolean {
return status === "done" || status === "completed";
function statusColor(status: string): string {
switch (status) {
case "todo":
case "pending": return "#F59E0B"; // yellow
case "in_progress": return "#3B82F6"; // blue
case "done": return "#22C55E"; // green
case "cancelled": return "#6B7280"; // gray
default: return "#F59E0B";
}
}
export default function TaskCard({ task, onComplete }: TaskCardProps) {
function userColor(userId: string): string {
const colors = [
"#3B82F6", "#8B5CF6", "#EC4899", "#F59E0B",
"#10B981", "#06B6D4", "#F97316", "#6366F1",
];
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash) + userId.charCodeAt(i);
hash |= 0;
}
return colors[Math.abs(hash) % colors.length];
}
function isDueSoon(dateStr: string): boolean {
const due = new Date(dateStr).getTime();
const now = Date.now();
return due - now <= 7 * 24 * 60 * 60 * 1000;
}
function isPast(dateStr: string): boolean {
return new Date(dateStr).getTime() < Date.now();
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export default function TaskCard({ task, onComplete, onAssign }: TaskCardProps) {
const { t } = useTranslation();
const priorityColor = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium;
const taskDone = isDone(task.status);
const taskDone = task.status === "done";
const [swipeOffset, setSwipeOffset] = useState(0);
const [swiped, setSwiped] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const SWIPE_THRESHOLD = 120;
const assignees = task.assigned_to || [];
const visibleAssignees = assignees.slice(0, 3);
const groupColor = task.group_color;
const sColor = statusColor(task.status);
const swipeHandlers = useSwipeable({
onSwiping: (e) => {
if (e.dir === "Right" && !taskDone && onComplete) {
const offset = Math.min(e.deltaX, 160);
setSwipeOffset(offset);
setSwipeOffset(Math.min(e.deltaX, 160));
}
},
onSwipedRight: (e) => {
if (e.absX > SWIPE_THRESHOLD && !taskDone && onComplete) {
setSwiped(true);
setTimeout(() => {
onComplete(task.id);
}, 300);
setTimeout(() => { onComplete(task.id); }, 300);
} else {
setSwipeOffset(0);
}
@@ -62,14 +89,12 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
const showCompleteHint = swipeOffset > 40;
return (
<div className="relative overflow-hidden rounded-lg" ref={cardRef}>
{/* Swipe background - green complete indicator */}
<div className="relative overflow-hidden" ref={cardRef} style={{ margin: "0 0 5px" }}>
{/* Swipe background */}
{onComplete && !taskDone && (
<div
className={`absolute inset-0 flex items-center pl-4 rounded-lg transition-colors ${
swipeOffset > SWIPE_THRESHOLD
? "bg-green-500"
: "bg-green-400/80"
className={`absolute inset-0 flex items-center pl-4 rounded-xl transition-colors ${
swipeOffset > SWIPE_THRESHOLD ? "bg-green-500" : "bg-green-400/80"
}`}
>
<div
@@ -92,39 +117,107 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
}}
>
<Link href={`/tasks/${task.id}`} className="block group">
<div className={`relative bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-150 active:scale-[0.99] ${swiped ? "opacity-0 transition-opacity duration-300" : ""}`}>
{/* Priority line on left edge */}
<Link href={`/tasks/${task.id}`} className="block">
<div
className="absolute left-0 top-0 bottom-0 w-0.5"
style={{ backgroundColor: priorityColor }}
/>
<div className="pl-3 pr-2.5 py-2 flex items-center gap-2">
{/* Group icon */}
{task.group_icon && (
<span className="text-base flex-shrink-0 leading-none">{task.group_icon}</span>
)}
{/* Title - single line, truncated */}
<h3
className={`text-sm font-medium truncate flex-1 min-w-0 ${
taskDone ? "line-through text-muted" : ""
}`}
style={{
padding: "10px 14px",
borderRadius: 10,
background: "#13131A",
border: `1px solid ${groupColor ? groupColor + "30" : "#1E1E2E"}`,
borderLeft: `3px solid ${groupColor || "#2A2A3A"}`,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 10,
opacity: swiped ? 0 : 1,
transition: swiped ? "opacity 0.3s" : undefined,
}}
>
{/* LEFT: title + optional due date */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "#E8E8F0",
textDecoration: taskDone ? "line-through" : "none",
opacity: taskDone ? 0.5 : 1,
}}>
{task.title}
</h3>
{/* Status badge */}
<div className="flex-shrink-0">
<StatusBadge status={task.status} size="sm" />
</div>
{task.due_at && isDueSoon(task.due_at) && (
<div style={{
fontSize: 11,
color: isPast(task.due_at) ? "#EF4444" : "#F59E0B",
marginTop: 2,
}}>
{formatDate(task.due_at)}
</div>
)}
</div>
{/* Priority dot */}
{/* RIGHT: avatars + big status dot */}
<div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
{/* Avatars */}
<div style={{ display: "flex", alignItems: "center" }}>
{visibleAssignees.map((userId, i) => (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: priorityColor }}
title={t(`tasks.priority.${task.priority}`)}
key={userId}
title={userId}
style={{
width: 26, height: 26,
borderRadius: "50%",
marginLeft: i > 0 ? -8 : 0,
border: "2px solid #13131A",
background: userColor(userId),
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 10, fontWeight: 700, color: "white",
zIndex: 3 - i,
position: "relative",
flexShrink: 0,
}}
>
{userId.slice(0, 2).toUpperCase()}
</div>
))}
{/* + add user button */}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onAssign) onAssign(task.id);
}}
title="Přidat uživatele"
style={{
width: 26, height: 26,
borderRadius: "50%",
marginLeft: visibleAssignees.length > 0 ? -8 : 0,
border: "2px dashed #3A3A5A",
background: "transparent",
cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 14, color: "#4A4A6A",
position: "relative", zIndex: 0,
flexShrink: 0,
}}
>
+
</div>
</div>
{/* Big colored status dot */}
<div
title={t(`tasks.status.${task.status}`)}
style={{
width: 14, height: 14,
borderRadius: "50%",
background: sColor,
flexShrink: 0,
boxShadow: `0 0 6px ${sColor}80`,
}}
/>
</div>
</div>

View File

@@ -0,0 +1,55 @@
'use client';
import IconButton from './IconButton';
interface Props {
onAssign: () => void;
onTransfer: () => void;
onClaim: () => void;
disabled: boolean;
t: (key: string) => string;
}
export default function CollabActionButtons({ onAssign, onTransfer, onClaim, disabled, t }: Props) {
return (
<div className="flex flex-wrap gap-2">
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
}
label={t("collab.assign")}
onClick={onAssign}
disabled={disabled}
variant="primary"
size="md"
/>
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
}
label={t("collab.transfer")}
onClick={onTransfer}
disabled={disabled}
variant="warning"
size="md"
/>
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0 0V14m0 0h2.5M7 14H4.5m4.5 0a6 6 0 1012 0 6 6 0 00-12 0z" />
</svg>
}
label={t("collab.claim")}
onClick={onClaim}
disabled={disabled}
variant="success"
size="md"
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import IconButton from './IconButton';
interface Props {
onClick: () => void;
label: string;
}
export default function CollabBackButton({ onClick, label }: Props) {
return (
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
}
label={label}
onClick={onClick}
variant="default"
size="md"
/>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/lib/auth';
import { useTheme } from '@/components/ThemeProvider';
import { useTranslation } from '@/lib/i18n';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import IconButton from './IconButton';
export default function CompactHeader() {
const { user, logout, token } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
const router = useRouter();
const [drawerOpen, setDrawerOpen] = useState(false);
function handleLogout() {
logout();
router.push('/login');
setDrawerOpen(false);
}
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
useEffect(() => {
if (!drawerOpen) return;
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') closeDrawer();
}
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [drawerOpen, closeDrawer]);
return (
<>
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
<div className="max-w-4xl mx-auto px-3 h-10 flex items-center justify-end gap-1.5">
{token && user && (
<button
onClick={openDrawer}
className="w-7 h-7 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 hover:ring-2 hover:ring-blue-400 transition-all flex-shrink-0"
title={user.name || user.email || t('common.menu')}
aria-label={t('common.menu')}
>
{(user.name || user.email || '?').charAt(0).toUpperCase()}
</button>
)}
<IconButton
icon={
<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" />
</svg>
}
label={t('common.menu')}
onClick={openDrawer}
variant="default"
size="sm"
className="!bg-transparent"
/>
</div>
</header>
{/* Slide-out drawer */}
{drawerOpen && (
<div className="fixed inset-0 z-[60]">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fadeIn"
onClick={closeDrawer}
/>
<div className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-gray-900 shadow-2xl animate-slideInRight flex flex-col">
{/* Close */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<span className="font-semibold text-lg">{t('common.menu')}</span>
<IconButton
icon={
<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>
}
label={t('common.closeMenu')}
onClick={closeDrawer}
variant="default"
size="md"
/>
</div>
{/* User info */}
{token && user && (
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold">
{(user.name || user.email || '?').charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<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>
</div>
)}
{/* Menu items - icons only with labels next to them in the drawer */}
<div className="p-2 space-y-1 flex-1 overflow-y-auto">
{[
{
href: '/tasks',
icon: <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>,
label: t('nav.tasks'),
},
{
href: '/calendar',
icon: <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>,
label: t('nav.calendar'),
},
{
href: '/chat',
icon: <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>,
label: t('nav.chat'),
},
{
href: '/settings',
icon: <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="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>,
label: t('nav.settings'),
},
{
href: '/settings#install',
icon: <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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>,
label: t('settings.install') || 'Instalace',
},
].map((item) => (
<Link
key={item.href}
href={item.href}
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
{item.icon}
<span className="text-sm font-medium">{item.label}</span>
</Link>
))}
{/* Theme toggle */}
<button
onClick={toggleTheme}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full text-left min-h-[48px]"
>
{theme === 'dark' ? (
<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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
<span className="text-sm font-medium">
{theme === 'dark' ? t('settings.light') : t('settings.dark')}
</span>
</button>
</div>
{/* Logout icon at bottom */}
{token && (
<div className="p-4 border-t border-gray-200 dark:border-gray-800 safe-area-bottom">
<IconButton
icon={
<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>
}
label={t('auth.logout')}
onClick={handleLogout}
variant="danger"
size="md"
/>
</div>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import IconButton from './IconButton';
interface Props {
onClick: () => void;
label: string;
size?: 'sm' | 'md' | 'lg';
}
export default function DeleteIconButton({ onClick, label, size = 'sm' }: Props) {
return (
<IconButton
icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
label={label}
onClick={onClick}
variant="danger"
size={size}
/>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import IconButton from './IconButton';
interface Props {
onPlan: () => void;
onReport: () => void;
onDelete: () => void;
planLoading: boolean;
reportLoading: boolean;
t: (key: string) => string;
}
export default function GoalActionButtons({ onPlan, onReport, onDelete, planLoading, reportLoading, t }: Props) {
return (
<div className="flex items-center gap-2">
<IconButton
icon={
planLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current" />
) : (
<svg className="w-5 h-5" 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>
)
}
label={t("goals.plan")}
onClick={onPlan}
disabled={planLoading}
variant="purple"
size="lg"
/>
<IconButton
icon={
reportLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current" />
) : (
<svg className="w-5 h-5" 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>
)
}
label={t("goals.report")}
onClick={onReport}
disabled={reportLoading}
variant="success"
size="lg"
/>
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
label={t("tasks.delete")}
onClick={onDelete}
variant="danger"
size="lg"
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { ReactNode } from 'react';
interface Props {
icon: ReactNode;
label: string;
onClick?: () => void;
className?: string;
variant?: 'default' | 'danger' | 'success' | 'primary' | 'warning' | 'purple';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
type?: 'button' | 'submit';
}
const variants: Record<string, string> = {
default: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300',
danger: 'bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-800/40 text-red-600 dark:text-red-400',
success: 'bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-800/40 text-green-600 dark:text-green-400',
primary: 'bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-800/40 text-blue-600 dark:text-blue-400',
warning: 'bg-yellow-100 dark:bg-yellow-900/30 hover:bg-yellow-200 dark:hover:bg-yellow-800/40 text-yellow-600 dark:text-yellow-400',
purple: 'bg-purple-100 dark:bg-purple-900/30 hover:bg-purple-200 dark:hover:bg-purple-800/40 text-purple-600 dark:text-purple-400',
};
const sizes: Record<string, string> = {
sm: 'w-8 h-8 text-sm',
md: 'w-10 h-10 text-base',
lg: 'w-12 h-12 text-lg',
};
export default function IconButton({
icon,
label,
onClick,
className,
variant = 'default',
size = 'md',
disabled,
type = 'button',
}: Props) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className={`inline-flex items-center justify-center rounded-lg transition-colors cursor-pointer ${variants[variant] || variants.default} ${sizes[size] || sizes.md} ${className || ''} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{icon}
</button>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useState, useRef, useEffect } from 'react';
interface Props {
value: string;
onSave: (newValue: string) => void;
className?: string;
as?: 'input' | 'textarea';
placeholder?: string;
multiline?: boolean;
}
export default function InlineEditField({
value,
onSave,
className = '',
as = 'input',
placeholder = '',
multiline = false,
}: Props) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
setEditValue(value);
}, [value]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
// Select all text
if ('setSelectionRange' in inputRef.current) {
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
}
}
}, [editing]);
function handleBlur() {
setEditing(false);
const trimmed = editValue.trim();
if (trimmed !== value && trimmed !== '') {
onSave(trimmed);
} else {
setEditValue(value);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !multiline) {
e.preventDefault();
(e.target as HTMLElement).blur();
}
if (e.key === 'Escape') {
setEditValue(value);
setEditing(false);
}
}
if (editing) {
if (as === 'textarea' || multiline) {
return (
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={3}
className={`w-full px-2 py-1 border border-blue-400 dark:border-blue-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none text-sm ${className}`}
/>
);
}
return (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`w-full px-2 py-1 border border-blue-400 dark:border-blue-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-sm ${className}`}
/>
);
}
return (
<div
onClick={() => setEditing(true)}
className={`cursor-pointer rounded-lg px-2 py-1 -mx-2 -my-1 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`}
title="Click to edit"
>
{value || <span className="text-muted italic">{placeholder || 'Click to edit'}</span>}
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { ReactNode } from 'react';
import IconButton from './IconButton';
interface Props {
title: string;
showAdd?: boolean;
onToggleAdd?: () => void;
addOpen?: boolean;
t: (key: string) => string;
children?: ReactNode;
}
export default function PageActionBar({ title, showAdd, onToggleAdd, addOpen, t, children }: Props) {
return (
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-bold dark:text-white truncate">{title}</h1>
<div className="flex items-center gap-2 flex-shrink-0">
{children}
{showAdd && onToggleAdd && (
<IconButton
icon={
addOpen ? (
<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>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)
}
label={addOpen ? t("tasks.form.cancel") : t("tasks.add")}
onClick={onToggleAdd}
variant={addOpen ? "danger" : "primary"}
size="md"
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import IconButton from './IconButton';
interface Props {
taskDone: boolean;
deleting: boolean;
onBack: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleStatus: () => void;
onInvite: () => void;
onCollaborate: () => void;
t: (key: string) => string;
}
export default function TaskDetailActions({
taskDone,
deleting,
onBack,
onEdit,
onDelete,
onToggleStatus,
onInvite,
onCollaborate,
t,
}: Props) {
return (
<div className="flex items-center gap-2">
{/* Back */}
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
}
label={t("common.back")}
onClick={onBack}
variant="default"
size="md"
/>
<div className="flex-1" />
{/* Toggle done/reopen */}
{!taskDone ? (
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
}
label={t("tasks.markDone")}
onClick={onToggleStatus}
variant="success"
size="md"
/>
) : (
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
label={t("tasks.reopen")}
onClick={onToggleStatus}
variant="warning"
size="md"
/>
)}
{/* Edit */}
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
}
label={t("tasks.edit")}
onClick={onEdit}
variant="primary"
size="md"
/>
{/* Delete */}
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
label={deleting ? t("tasks.deleting") : t("tasks.delete")}
onClick={onDelete}
disabled={deleting}
variant="danger"
size="md"
/>
{/* Collaborate */}
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
}
label={t("collab.collaboration")}
onClick={onCollaborate}
variant="purple"
size="md"
/>
{/* Invite */}
<IconButton
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
}
label="Pozvat"
onClick={onInvite}
variant="primary"
size="md"
/>
</div>
);
}

View File

@@ -135,12 +135,28 @@ export interface Task {
group_icon: string | null;
}
export interface GroupTimeZone {
days: number[];
from: string;
to: string;
}
export interface GroupLocation {
name: string;
lat: number | null;
lng: number | null;
radius_m: number;
}
export interface Group {
id: string;
name: string;
color: string;
icon: string | null;
sort_order: number;
display_name?: string;
time_zones: GroupTimeZone[];
locations: GroupLocation[];
}
export interface Connector {

View File

@@ -12,6 +12,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
@@ -559,6 +560,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-12.0.0.tgz",
"integrity": "sha512-0w6W8qkACycyaRMb2XnHfpA9kkgs5e2Aw2Ul9ObBYmvFBbtzipyWu9u2+WP1wy98chM+GIlQFnPheUbiMBQr8w==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/types": "^12.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",

View File

@@ -13,6 +13,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",

View File

@@ -1 +1,46 @@
{"name":"Task Team","short_name":"Tasks","start_url":"/","display":"standalone","background_color":"#0A0A0F","theme_color":"#1D4ED8","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
{
"name": "Task Team",
"short_name": "Tasks",
"start_url": "/",
"display": "standalone",
"background_color": "#0A0A0F",
"theme_color": "#1D4ED8",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Novy ukol",
"short_name": "Pridat",
"url": "/tasks?action=new",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Widget",
"short_name": "Widget",
"url": "/widget",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192"
}
]
}
]
}