Compare commits

..

14 Commits

Author SHA1 Message Date
1fbbc84d24 WebAuthn biometric UI: login button + device management in settings
- Login page: "Face ID / Otisk prstu" button with full WebAuthn flow
  (auth options → navigator.credentials.get → verify → JWT)
  Remembers last biometric email in localStorage
- Settings page: Biometric device management section
  (list registered devices, add new via navigator.credentials.create, remove)
  Auto-detects device type (Face ID, Touch ID, Android fingerprint, Windows Hello)
- API: Added POST /webauthn/auth/verify endpoint returning JWT token
  Updated auth/options to accept email (no login required for biometric)
- API client: Added 6 WebAuthn helper functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:41:38 +00:00
4ace4d5f7d PWA widgets dashboard, lock screen, screensaver with active category
- Dashboard (/dashboard): configurable widget system with 6 widget types
  (current_tasks, category_time, today_progress, next_task, motivace, calendar_mini)
  stored in localStorage widget_config
- Lock screen (/lockscreen): fullscreen with clock, active group badge,
  up to 4 current tasks, gradient from group color, swipe/tap to unlock
- InactivityMonitor: auto-redirect to lockscreen after configurable timeout
  (only in PWA standalone/fullscreen mode)
- Settings: widget toggle switches, inactivity timeout slider, lockscreen preview
- manifest.json: added display_override ["fullscreen","standalone"] + orientation portrait

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:32:08 +00:00
f2915b79fa feat(tasks): add inline title editing and status cycling in task list
Click task title to edit inline (Enter/blur saves via PUT API).
Click status dot to cycle pending -> in_progress -> done.
Optimistic UI updates with rollback on error.
Detail page still accessible by clicking other card areas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:53:23 +00:00
7a9b74faf8 Strict CLAUDE.md rules: no fake Done, mandatory testing, port 22770
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:57:45 +00:00
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
52 changed files with 4252 additions and 616 deletions

View File

@@ -47,3 +47,37 @@ Pracuješ autonomně na projektu Task Team.
/opt/task-team/assets/brand/ /opt/task-team/assets/brand/
/opt/task-team/db/migrations/ /opt/task-team/db/migrations/
/opt/n8n/ /opt/n8n/
## POVINNÁ PRAVIDLA — PLATÍ OD 2026-03-30
### ZAKÁZÁNO:
- Nastavit Status=Done BEZ curl/psql ověření že feature funguje
- Kopírovat feedback z jiného tasku
- Přidávat tasky jiných projektů do Task Team DB
- Používat port 22 (je to HONEYPOT → 10yr ban). VŽDY port 22770!
### POVINNÝ FORMÁT SERVER FEEDBACK:
```
✅ YYYY-MM-DD HH:MM UTC
SERVER: hostname (IP)
TESTOVÁNO: curl https://... NEBO psql -c "SELECT..."
VÝSLEDEK: HTTP 200, {"data":...}
CO BYLO ZMĚNĚNO: soubor1.js, soubor2.tsx
GIT COMMIT: hash
```
### POVINNÝ POSTUP PŘI IMPLEMENTACI:
1. git pull origin master
2. Implementuj změny
3. npm run build (frontend) NEBO pm2 reload (API)
4. TESTUJ: curl endpoint NEBO otevři stránku
5. Až FUNGUJE → git add + commit + push
6. Až PUSHNUTÉ → Notion Status=Done + Server Feedback s důkazem
### SSH:
- VŽDY: ssh -p 22770 root@136.243.43.144
- NIKDY: ssh root@136.243.43.144 (port 22 = honeypot = ban)
### NOTION DB:
- Dev Tasks: 659a5381-564a-453a-9e2b-1345c457cca9 (SPRÁVNÉ ID)
- NEPOUŽÍVAT: bc097386-efef-4b83-9a03-0aca082570db (collection ID, nefunguje s API)

187
api/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@fastify/swagger": "^9.7.0", "@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^12.0.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@@ -389,12 +390,24 @@
"ws": "^8.16.0" "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": { "node_modules/@ioredis/commands": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT" "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": { "node_modules/@lukeed/ms": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@@ -404,6 +417,64 @@
"node": ">=8" "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": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -479,6 +550,33 @@
"@redis/client": "^5.11.0" "@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": { "node_modules/abstract-logging": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -553,6 +651,20 @@
"safer-buffer": "^2.1.0" "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": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -733,6 +845,15 @@
"url": "https://opencollective.com/express" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1425,6 +1546,26 @@
"node": "^18 || ^20 || >= 21" "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": { "node_modules/node-gyp-build": {
"version": "4.8.4", "version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -1813,6 +1954,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/quick-format-unescaped": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -2163,12 +2322,24 @@
"nodetouch": "bin/nodetouch.js" "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": { "node_modules/ts-algebra": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT" "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": { "node_modules/uid2": {
"version": "0.0.4", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
@@ -2229,6 +2400,22 @@
"node": ">= 16" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -21,6 +21,7 @@
"@fastify/swagger": "^9.7.0", "@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^12.0.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.3.1", "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,88 @@
// 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 (by email — no login required)
app.post('/webauthn/auth/options', async (req) => {
const { user_id, email } = req.body;
let userId = user_id;
if (!userId && email) {
const { rows: u } = await app.db.query('SELECT id FROM users WHERE email=$1', [email]);
if (!u.length) throw { statusCode: 404, message: 'User not found' };
userId = u[0].id;
}
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [userId]);
if (!rows.length) throw { statusCode: 404, message: 'No biometric credentials registered' };
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
timeout: 60000, userVerification: 'required',
_user_id: userId
}};
});
// Verify auth assertion — returns JWT
app.post('/webauthn/auth/verify', async (req) => {
const { credential_id } = req.body;
if (!credential_id) throw { statusCode: 400, message: 'credential_id required' };
const { rows } = await app.db.query(
`SELECT wc.user_id, wc.counter, u.id, u.email, u.name
FROM webauthn_credentials wc JOIN users u ON u.id = wc.user_id
WHERE wc.credential_id = $1`, [credential_id]);
if (!rows.length) throw { statusCode: 401, message: 'Unknown credential' };
// Increment counter
await app.db.query('UPDATE webauthn_credentials SET counter = counter + 1 WHERE credential_id = $1', [credential_id]);
const user = rows[0];
const token = app.jwt.sign({ id: user.id, email: user.email }, { expiresIn: '7d' });
return { data: { token, user: { id: user.id, email: user.email, name: user.name } } };
});
// 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, { await app.register(rateLimit, {
max: 100, max: 100,
timeWindow: "1 minute", 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 }) errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
}); });
await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" }); await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
@@ -60,8 +70,6 @@ const start = async () => {
pid: process.pid, pid: process.pid,
redis: redis.status redis: redis.status
})); }));
// Swagger/OpenAPI documentation // Swagger/OpenAPI documentation
await app.register(swagger, { await app.register(swagger, {
openapi: { openapi: {
@@ -115,7 +123,16 @@ const start = async () => {
await app.register(require("./routes/email"), { prefix: "/api/v1" }); await app.register(require("./routes/email"), { prefix: "/api/v1" });
await app.register(require("./routes/errors"), { 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/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 { try {
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); 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 + ")"); 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) => { 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( const { rows } = await app.db.query(
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon), `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() order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones),
WHERE id=$6 RETURNING *`, locations=COALESCE($6,locations), updated_at=NOW()
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id] 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' }; if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
return { data: rows[0] }; return { data: rows[0] };
@@ -111,6 +115,27 @@ async function groupRoutes(app) {
return { data: rows[0] }; 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; 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 }, 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; if (!token) return null;
return ( return (
<div className="p-4"> <div className="pb-24 sm:pb-8 px-4 sm:px-0">
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1> {/* 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 && ( {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"> <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} {error}
</div> </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 <FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek" initialView="timeGridWeek"

View File

@@ -0,0 +1,283 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { getTasks, getGroups, Task, Group } from "@/lib/api";
import Link from "next/link";
type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini";
const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"];
const WIDGET_LABELS: Record<WidgetType, string> = {
current_tasks: "Aktualni ukoly",
category_time: "Aktivni kategorie",
today_progress: "Dnesni pokrok",
next_task: "Pristi ukol",
motivace: "Motivace",
calendar_mini: "Mini kalendar",
};
const MOTIVACE_LIST = [
"Kazdy ukol, ktery dokoncis, te priblizuje k cili.",
"Male kroky vedou k velkym vysledkum.",
"Dnes je skvely den na splneni ukolu.",
"Soustred se na to, co muzes ovlivnit.",
"Tvoje prace ma smysl. Pokracuj!",
"Disciplina premaha talent, kdyz talent nema disciplinu.",
"Nejlepsi cas zacit byl vcera. Druhy nejlepsi je ted.",
];
function getActiveGroup(groups: Group[]): Group | null {
const now = new Date();
const currentDay = now.getDay();
const pad = (n: number) => String(n).padStart(2, "0");
const currentTime = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
for (const group of groups) {
for (const tz of group.time_zones || []) {
if (tz.days?.length && !tz.days.includes(currentDay)) continue;
if (tz.from && tz.to && tz.from <= currentTime && currentTime <= tz.to) return group;
}
}
return groups[0] || null;
}
function getTodayProgress(tasks: Task[]) {
const today = new Date().toISOString().slice(0, 10);
const all = tasks.filter(t =>
t.due_at?.startsWith(today) || t.scheduled_at?.startsWith(today) ||
(t.status !== "cancelled" && t.created_at?.startsWith(today))
);
const done = all.filter(t => t.status === "done").length;
return { done, total: all.length };
}
function getNextTask(tasks: Task[]): Task | null {
const now = new Date().toISOString();
return tasks
.filter(t => t.status !== "done" && t.status !== "cancelled" && t.due_at && t.due_at > now)
.sort((a, b) => (a.due_at! > b.due_at! ? 1 : -1))[0] || null;
}
export default function DashboardPage() {
const { token } = useAuth();
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [enabledWidgets, setEnabledWidgets] = useState<WidgetType[]>(DEFAULT_WIDGETS);
const [motivace] = useState(() => MOTIVACE_LIST[Math.floor(Math.random() * MOTIVACE_LIST.length)]);
const [now, setNow] = useState(new Date());
useEffect(() => {
if (!token) { router.replace("/login"); return; }
try {
const stored = localStorage.getItem("widget_config");
if (stored) {
const cfg = JSON.parse(stored);
if (Array.isArray(cfg.enabled) && cfg.enabled.length > 0) {
setEnabledWidgets(cfg.enabled);
}
}
} catch { /* ignore */ }
}, [token, router]);
const loadData = useCallback(async () => {
if (!token) return;
try {
const [tasksRes, groupsRes] = await Promise.all([getTasks(token), getGroups(token)]);
setTasks(tasksRes.data || []);
setGroups(groupsRes.data || []);
} catch { /* ignore */ }
setLoading(false);
}, [token]);
useEffect(() => { loadData(); }, [loadData]);
// Refresh every 2 minutes
useEffect(() => {
const i = setInterval(loadData, 2 * 60 * 1000);
return () => clearInterval(i);
}, [loadData]);
// Update clock every minute
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 60000);
return () => clearInterval(t);
}, []);
if (!token) return null;
const activeGroup = getActiveGroup(groups);
const activeTasks = tasks.filter(t => t.status === "pending" || t.status === "in_progress");
const progress = getTodayProgress(tasks);
const nextTask = getNextTask(tasks);
const acColor = activeGroup?.color || "#4F46E5";
const pad = (n: number) => String(n).padStart(2, "0");
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
function renderWidget(wType: WidgetType) {
switch (wType) {
case "current_tasks":
return (
<div key="current_tasks" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800" style={{ borderLeftColor: acColor, borderLeftWidth: 3 }}>
<div className="flex justify-between items-center mb-3">
<span className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider">Aktualni ukoly</span>
<Link href="/tasks" className="text-xs" style={{ color: acColor }}>Zobrazit vse &rarr;</Link>
</div>
{loading ? (
<div className="text-sm text-gray-400">Nacitam...</div>
) : activeTasks.length === 0 ? (
<div className="text-sm text-gray-400 text-center py-3">Zadne aktivni ukoly</div>
) : (
<div className="flex flex-col gap-2">
{activeTasks.slice(0, 5).map(task => (
<div key={task.id} className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: task.status === "in_progress" ? "#60A5FA" : "#FBBF24" }} />
<span className="text-sm dark:text-[#F0F0F5] text-gray-800 truncate flex-1">{task.title}</span>
{task.group_icon && <span className="text-sm">{task.group_icon}</span>}
</div>
))}
</div>
)}
</div>
);
case "category_time":
if (!activeGroup) return null;
return (
<div key="category_time" className="rounded-2xl p-4 border" style={{ background: `linear-gradient(135deg, ${acColor}15, transparent)`, borderColor: `${acColor}30` }}>
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Aktivni kategorie</div>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{ background: `${acColor}20` }}>
{activeGroup.icon || "\uD83D\uDCC1"}
</div>
<div>
<div className="text-lg font-bold dark:text-[#F0F0F5] text-gray-900">{activeGroup.display_name || activeGroup.name}</div>
{activeGroup.time_zones?.[0] && (
<div className="text-sm mt-0.5" style={{ color: acColor }}>
{activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to}
</div>
)}
</div>
</div>
</div>
);
case "today_progress":
return (
<div key="today_progress" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Dnesni pokrok</div>
<div className="flex items-baseline gap-2 mb-2.5">
<span className="text-3xl font-extrabold text-emerald-400">{progress.done}</span>
<span className="text-base text-gray-500">/ {progress.total} ukolu</span>
</div>
{progress.total > 0 && (
<div className="h-2 bg-gray-200 dark:bg-[#2A2A3A] rounded-full overflow-hidden">
<div
className="h-full bg-emerald-400 rounded-full transition-all duration-500"
style={{ width: `${Math.round((progress.done / progress.total) * 100)}%` }}
/>
</div>
)}
{progress.total === 0 && (
<div className="text-sm text-gray-400">Zadne ukoly na dnes</div>
)}
</div>
);
case "next_task":
return (
<div key="next_task" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Pristi ukol</div>
{nextTask ? (
<div>
<div className="text-[15px] font-semibold dark:text-[#F0F0F5] text-gray-900 mb-1">{nextTask.title}</div>
{nextTask.due_at && (
<div className="text-sm text-amber-400">
{new Date(nextTask.due_at).toLocaleString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-400">Zadne nadchazejici ukoly</div>
)}
</div>
);
case "motivace":
return (
<div key="motivace" className="rounded-2xl p-4 border" style={{ background: "linear-gradient(135deg, #4F46E510, transparent)", borderColor: "#4F46E530" }}>
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-2">Motivace</div>
<div className="text-[15px] text-gray-600 dark:text-[#C7C7D9] leading-relaxed">{motivace}</div>
</div>
);
case "calendar_mini": {
const DAYS = ["Po", "Ut", "St", "Ct", "Pa", "So", "Ne"];
return (
<div key="calendar_mini" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Tento tyden</div>
<div className="flex gap-1">
{Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
const dow = (d.getDay() + 6) % 7; // Mon=0
d.setDate(d.getDate() + (i - dow));
const dateStr = d.toISOString().slice(0, 10);
const isToday = dateStr === now.toISOString().slice(0, 10);
const taskCount = tasks.filter(t => t.due_at?.startsWith(dateStr) || t.scheduled_at?.startsWith(dateStr)).length;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-500 dark:text-[#6B6B85]">{DAYS[i]}</span>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-[13px] ${
isToday ? "bg-blue-600 text-white font-bold" : taskCount > 0 ? "bg-blue-600/10 dark:text-[#F0F0F5] text-gray-800" : "border border-gray-200 dark:border-[#2A2A3A] dark:text-[#F0F0F5] text-gray-800"
}`}
>
{d.getDate()}
</div>
{taskCount > 0 && <div className="w-1 h-1 rounded-full bg-blue-400" />}
</div>
);
})}
</div>
</div>
);
}
}
}
return (
<div className="max-w-lg mx-auto px-4 pb-24">
{/* Header with time */}
<div className="flex justify-between items-center py-4 pb-5">
<div>
<div className="text-3xl font-extrabold dark:text-[#F0F0F5] text-gray-900 leading-none">{timeStr}</div>
<div className="text-sm text-gray-500 dark:text-[#6B6B85] mt-1 capitalize">
{now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" })}
</div>
</div>
<div className="flex gap-2">
<Link href="/lockscreen" className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-[#13131A] flex items-center justify-center border border-gray-200 dark:border-gray-800" title="Zamykaci obrazovka">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} className="text-gray-500 dark:text-[#6B6B85]">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</Link>
<Link href="/settings" className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-[#13131A] flex items-center justify-center border border-gray-200 dark:border-gray-800">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} className="text-gray-500 dark:text-[#6B6B85]">
<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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
</Link>
</div>
</div>
{/* Widgets */}
<div className="flex flex-col gap-3">
{enabledWidgets.map(wType => renderWidget(wType))}
</div>
</div>
);
}

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 { .fc .fc-toolbar {
flex-wrap: wrap; display: flex;
gap: 4px 8px; flex-wrap: nowrap;
row-gap: 6px; align-items: center;
gap: 6px;
font-size: 14px; 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 { .fc .fc-toolbar-title {
font-size: 18px !important; font-size: 16px !important;
font-weight: 700;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -275,56 +294,49 @@ main {
/* Compact buttons */ /* Compact buttons */
.fc .fc-button { .fc .fc-button {
padding: 4px 10px !important; padding: 4px 8px !important;
font-size: 13px !important; font-size: 12px !important;
line-height: 1.4 !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 { .fc .fc-button-group {
gap: 0; gap: 0;
} }
.fc .fc-button-group .fc-button {
border-radius: 0 !important;
}
.fc .fc-button-group .fc-button:first-child {
border-radius: 9999px 0 0 9999px !important;
}
.fc .fc-button-group .fc-button:last-child {
border-radius: 0 9999px 9999px 0 !important;
}
/* Mobile breakpoint: stack toolbar rows */ /* Mobile: tighten further but keep single row */
@media (max-width: 640px) { @media (max-width: 640px) {
.fc .fc-toolbar { .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; gap: 4px;
width: 100%;
} }
/* Title on its own row, centered, smaller */
.fc .fc-toolbar-title { .fc .fc-toolbar-title {
font-size: 15px !important; font-size: 13px !important;
text-align: center;
width: 100%;
order: -1;
} }
/* Smaller buttons on mobile */
.fc .fc-button { .fc .fc-button {
padding: 3px 8px !important; padding: 3px 6px !important;
font-size: 12px !important; font-size: 11px !important;
min-height: 32px !important; min-height: 32px !important;
} }
/* View switcher as pill buttons */ /* Hide text labels on smallest screens - show abbreviated */
.fc .fc-button-group .fc-button { .fc .fc-dayGridMonth-button,
border-radius: 0 !important; .fc .fc-timeGridWeek-button,
} .fc .fc-timeGridDay-button {
.fc .fc-button-group .fc-button:first-child { font-size: 10px !important;
border-radius: 9999px 0 0 9999px !important; padding: 3px 5px !important;
}
.fc .fc-button-group .fc-button:last-child {
border-radius: 0 9999px 9999px 0 !important;
} }
/* Reduce page padding */ /* Reduce page padding */
@@ -339,20 +351,72 @@ main {
/* Day header smaller */ /* Day header smaller */
.fc .fc-col-header-cell-cushion { .fc .fc-col-header-cell-cushion {
font-size: 12px; font-size: 11px;
padding: 4px 2px; padding: 3px 2px;
} }
} }
/* Small mobile (< 400px) - even tighter */ /* Small mobile (< 400px) - even tighter */
@media (max-width: 400px) { @media (max-width: 400px) {
.fc .fc-toolbar-title { .fc .fc-toolbar-title {
font-size: 13px !important; font-size: 12px !important;
} }
.fc .fc-button { .fc .fc-button {
padding: 2px 6px !important; padding: 2px 4px !important;
font-size: 11px !important; font-size: 10px !important;
min-height: 28px !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, GoalReport,
Group, Group,
} from "@/lib/api"; } 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() { export default function GoalsPage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -33,7 +36,6 @@ export default function GoalsPage() {
const [aiLoading, setAiLoading] = useState<string | null>(null); const [aiLoading, setAiLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Form state
const [formTitle, setFormTitle] = useState(""); const [formTitle, setFormTitle] = useState("");
const [formDate, setFormDate] = useState(""); const [formDate, setFormDate] = useState("");
const [formGroup, setFormGroup] = useState(""); const [formGroup, setFormGroup] = useState("");
@@ -172,18 +174,14 @@ export default function GoalsPage() {
return ( return (
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0"> <div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
{/* Header */} <PageActionBar
<div className="flex items-center justify-between"> title={t("goals.title")}
<h1 className="text-xl font-bold dark:text-white">{t("goals.title")}</h1> showAdd
<button onToggleAdd={() => setShowForm(!showForm)}
onClick={() => setShowForm(!showForm)} addOpen={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]" t={t}
> />
{showForm ? t("tasks.form.cancel") : `+ ${t("goals.add")}`}
</button>
</div>
{/* Error */}
{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"> <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} {error}
@@ -191,7 +189,6 @@ export default function GoalsPage() {
</div> </div>
)} )}
{/* Create form */}
{showForm && ( {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"> <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> <div>
@@ -237,7 +234,6 @@ export default function GoalsPage() {
</form> </form>
)} )}
{/* Goals list */}
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /> <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}% {goal.progress_pct}%
</span> </span>
</div> </div>
{/* Progress bar */}
<div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden"> <div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`} className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`}
@@ -294,20 +289,23 @@ export default function GoalsPage() {
{selectedGoal && ( {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="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 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> <h2 className="text-lg font-bold dark:text-white">{selectedGoal.title}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{formatDate(selectedGoal.target_date)} | {t("goals.progress")}: {selectedGoal.progress_pct}% {formatDate(selectedGoal.target_date)} | {t("goals.progress")}: {selectedGoal.progress_pct}%
</p> </p>
</div> </div>
<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="M6 18L18 6M6 6l12 12" />
</svg>
}
label={t("tasks.close")}
onClick={() => setSelectedGoal(null)} 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" variant="default"
> size="md"
<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> </div>
{/* Progress slider */} {/* Progress slider */}
@@ -325,47 +323,15 @@ export default function GoalsPage() {
/> />
</div> </div>
{/* AI Action buttons */} {/* AI Action buttons - icon only */}
<div className="flex gap-2"> <GoalActionButtons
<button onPlan={() => handleGeneratePlan(selectedGoal.id)}
onClick={() => handleGeneratePlan(selectedGoal.id)} onReport={() => handleGetReport(selectedGoal.id)}
disabled={aiLoading === "plan"} onDelete={() => handleDelete(selectedGoal.id)}
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]" planLoading={aiLoading === "plan"}
> reportLoading={aiLoading === "report"}
{aiLoading === "plan" ? ( t={t}
<> />
<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>
{/* Plan result */} {/* Plan result */}
{planResult && ( {planResult && (
@@ -468,14 +434,6 @@ export default function GoalsPage() {
</div> </div>
</div> </div>
)} )}
{/* Delete button */}
<button
onClick={() => handleDelete(selectedGoal.id)}
className="w-full py-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-sm font-medium transition-colors min-h-[44px]"
>
{t("tasks.delete")}
</button>
</div> </div>
)} )}
</div> </div>

View File

@@ -4,6 +4,7 @@ import ThemeProvider from "@/components/ThemeProvider";
import AuthProvider from "@/components/AuthProvider"; import AuthProvider from "@/components/AuthProvider";
import Header from "@/components/Header"; import Header from "@/components/Header";
import BottomNav from "@/components/BottomNav"; import BottomNav from "@/components/BottomNav";
import InactivityMonitor from "@/components/InactivityMonitor";
import { I18nProvider } from "@/lib/i18n"; import { I18nProvider } from "@/lib/i18n";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -44,6 +45,7 @@ export default function RootLayout({
{children} {children}
</main> </main>
<BottomNav /> <BottomNav />
<InactivityMonitor />
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</I18nProvider> </I18nProvider>

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { getTasks, getGroups, Task, Group } from "@/lib/api";
function getActiveGroup(groups: Group[]): Group | null {
const now = new Date();
const currentDay = now.getDay();
const pad = (n: number) => String(n).padStart(2, "0");
const currentTime = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
for (const group of groups) {
for (const tz of group.time_zones || []) {
if (tz.days?.length && !tz.days.includes(currentDay)) continue;
if (tz.from && tz.to && tz.from <= currentTime && currentTime <= tz.to) return group;
}
}
return groups[0] || null;
}
function hexToRgb(hex: string): string {
const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!r) return "29, 78, 216";
return `${parseInt(r[1], 16)}, ${parseInt(r[2], 16)}, ${parseInt(r[3], 16)}`;
}
export default function LockScreen() {
const { token } = useAuth();
const router = useRouter();
const [now, setNow] = useState(new Date());
const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [swipeStart, setSwipeStart] = useState<number | null>(null);
const [swipeOffset, setSwipeOffset] = useState(0);
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
const loadData = useCallback(async () => {
if (!token) return;
try {
const [tasksRes, groupsRes] = await Promise.all([getTasks(token), getGroups(token)]);
setTasks(tasksRes.data || []);
setGroups(groupsRes.data || []);
} catch { /* ignore */ }
}, [token]);
useEffect(() => { loadData(); }, [loadData]);
// Reload data every 5 minutes
useEffect(() => {
const i = setInterval(loadData, 5 * 60 * 1000);
return () => clearInterval(i);
}, [loadData]);
function handleUnlock() {
router.push("/tasks");
}
function handleTouchStart(e: React.TouchEvent) {
setSwipeStart(e.touches[0].clientY);
setSwipeOffset(0);
}
function handleTouchMove(e: React.TouchEvent) {
if (swipeStart === null) return;
const delta = swipeStart - e.touches[0].clientY;
if (delta > 0) setSwipeOffset(Math.min(delta, 150));
}
function handleTouchEnd() {
if (swipeOffset > 80) handleUnlock();
setSwipeStart(null);
setSwipeOffset(0);
}
const activeGroup = getActiveGroup(groups);
const activeTasks = tasks
.filter(t => t.status === "pending" || t.status === "in_progress")
.slice(0, 4);
const color = activeGroup?.color || "#1D4ED8";
const rgb = hexToRgb(color);
const pad = (n: number) => String(n).padStart(2, "0");
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
const seconds = pad(now.getSeconds());
const dateStr = now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" });
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 9999,
background: `linear-gradient(160deg, rgba(${rgb}, 0.35) 0%, rgba(${rgb}, 0.08) 30%, #0A0A0F 60%)`,
display: "flex",
flexDirection: "column",
userSelect: "none",
cursor: "pointer",
transform: swipeOffset > 0 ? `translateY(-${swipeOffset * 0.3}px)` : undefined,
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
overflow: "hidden",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={handleUnlock}
>
{/* Ambient glow */}
<div style={{
position: "absolute",
top: -100,
left: "50%",
transform: "translateX(-50%)",
width: 400,
height: 400,
borderRadius: "50%",
background: `radial-gradient(circle, rgba(${rgb}, 0.15) 0%, transparent 70%)`,
pointerEvents: "none",
}} />
{/* Clock section */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", paddingTop: 40 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
<span style={{ fontSize: 88, fontWeight: 200, color: "#FFFFFF", letterSpacing: -4, lineHeight: 1, fontFamily: "system-ui" }}>
{timeStr}
</span>
<span style={{ fontSize: 28, fontWeight: 200, color: "rgba(255,255,255,0.4)" }}>
{seconds}
</span>
</div>
<div style={{ fontSize: 17, color: "rgba(255,255,255,0.5)", marginTop: 8, textTransform: "capitalize" }}>
{dateStr}
</div>
{/* Active group badge */}
{activeGroup && (
<div style={{
marginTop: 36,
display: "flex",
alignItems: "center",
gap: 14,
background: "rgba(255,255,255,0.06)",
borderRadius: 20,
padding: "14px 24px",
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.08)",
}}>
<span style={{ fontSize: 32 }}>{activeGroup.icon || "\uD83D\uDCC1"}</span>
<div>
<div style={{ fontSize: 19, fontWeight: 600, color: "#FFFFFF", letterSpacing: 0.3 }}>
{activeGroup.display_name || activeGroup.name}
</div>
{activeGroup.time_zones?.[0] && (
<div style={{ fontSize: 13, color, marginTop: 3, opacity: 0.9 }}>
{activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to}
</div>
)}
</div>
</div>
)}
</div>
{/* Tasks panel */}
{activeTasks.length > 0 && (
<div style={{ padding: "0 20px 24px" }}>
<div style={{
background: "rgba(255,255,255,0.05)",
borderRadius: 24,
padding: "16px 20px",
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.06)",
}}>
<div style={{
fontSize: 11,
color: "rgba(255,255,255,0.35)",
textTransform: "uppercase",
letterSpacing: 1.5,
fontWeight: 600,
marginBottom: 12,
}}>
\u00DAkoly ({activeTasks.length})
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{activeTasks.map(task => (
<div key={task.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
width: 7,
height: 7,
borderRadius: "50%",
background: task.status === "in_progress" ? "#60A5FA" : "#FBBF24",
flexShrink: 0,
boxShadow: `0 0 6px ${task.status === "in_progress" ? "#60A5FA" : "#FBBF24"}80`,
}} />
<span style={{
fontSize: 15,
color: "rgba(255,255,255,0.8)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{task.title}
</span>
{task.group_icon && <span style={{ fontSize: 14 }}>{task.group_icon}</span>}
</div>
))}
</div>
</div>
</div>
)}
{/* Unlock indicator */}
<div style={{
paddingBottom: 44,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}>
<div style={{
width: 36,
height: 5,
borderRadius: 3,
background: "rgba(255,255,255,0.25)",
}} />
<div style={{
fontSize: 13,
color: "rgba(255,255,255,0.3)",
letterSpacing: 0.5,
}}>
Klepn\u011Bte nebo p\u0159eje\u010Fte nahoru
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { login } from "@/lib/api"; import { login, webauthnAuthOptions, webauthnAuthVerify } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
@@ -12,11 +12,26 @@ export default function LoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [biometricLoading, setBiometricLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [savedEmail, setSavedEmail] = useState("");
const { setAuth } = useAuth(); const { setAuth } = useAuth();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
useEffect(() => {
// Check if WebAuthn is available
if (typeof window !== "undefined" && window.PublicKeyCredential) {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.()
.then(available => setBiometricAvailable(available))
.catch(() => {});
}
// Load last used email for biometric
const last = localStorage.getItem("taskteam_biometric_email");
if (last) setSavedEmail(last);
}, []);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!email.trim()) { if (!email.trim()) {
@@ -36,6 +51,57 @@ export default function LoginPage() {
} }
} }
async function handleBiometricLogin() {
const biometricEmail = email.trim() || savedEmail;
if (!biometricEmail) {
setError("Zadejte email pro biometricke prihlaseni");
return;
}
setBiometricLoading(true);
setError("");
try {
// Get auth options from server
const optionsRes = await webauthnAuthOptions(biometricEmail);
const options = optionsRes.data;
// Create PublicKey credential request
const allowCredentials = (options.allowCredentials as Array<{ id: string; type: string }>).map(cred => ({
id: base64urlToBuffer(cred.id),
type: cred.type as PublicKeyCredentialType,
}));
const credential = await navigator.credentials.get({
publicKey: {
challenge: base64urlToBuffer(options.challenge as string),
allowCredentials,
timeout: 60000,
userVerification: "required" as UserVerificationRequirement,
},
}) as PublicKeyCredential;
if (!credential) throw new Error("Biometricke overeni selhalo");
// Send credential_id to server to get JWT
const credentialId = bufferToBase64url(credential.rawId);
const result = await webauthnAuthVerify(credentialId);
// Save email for next time
localStorage.setItem("taskteam_biometric_email", biometricEmail);
setAuth(result.data.token, result.data.user);
router.push("/tasks");
} catch (err) {
const msg = err instanceof Error ? err.message : "Biometricke prihlaseni selhalo";
if (msg.includes("No biometric") || msg.includes("not found")) {
setError("Pro tento ucet neni nastaveno biometricke prihlaseni. Nastavte ho v Nastaveni.");
} else {
setError(msg);
}
} finally {
setBiometricLoading(false);
}
}
return ( return (
<div className="min-h-[70vh] flex items-center justify-center"> <div className="min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
@@ -93,6 +159,35 @@ export default function LoginPage() {
</button> </button>
</form> </form>
{/* Biometric Login */}
{biometricAvailable && (
<>
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
<span className="text-xs text-gray-400 uppercase">nebo</span>
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
</div>
<button
onClick={handleBiometricLogin}
disabled={biometricLoading}
className="w-full flex items-center justify-center gap-3 py-2.5 px-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 transition-colors disabled:opacity-50 bg-gray-50 dark:bg-gray-800/50"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-blue-600 dark:text-blue-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a48.667 48.667 0 00-1.418 8.773M11.997 3.001A7.5 7.5 0 0014.5 10.5c0 2.887-.543 5.649-1.533 8.19M9.003 3.001a7.5 7.5 0 00-2.497 7.499 48.12 48.12 0 01-.544 5M12 10.5a2.25 2.25 0 10-4.5 0 2.25 2.25 0 004.5 0z" />
</svg>
<span className="font-medium text-sm">
{biometricLoading ? "Overuji..." : "Face ID / Otisk prstu"}
</span>
</button>
{savedEmail && !email && (
<p className="text-xs text-gray-400 text-center mt-2">
Posledni ucet: {savedEmail}
</p>
)}
</>
)}
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<Link href="/forgot-password" className="text-sm text-blue-600 hover:underline"> <Link href="/forgot-password" className="text-sm text-blue-600 hover:underline">
{t("auth.forgotPassword")} {t("auth.forgotPassword")}
@@ -110,3 +205,20 @@ export default function LoginPage() {
</div> </div>
); );
} }
// --- WebAuthn buffer helpers ---
function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4));
const binary = atob(base64 + pad);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

View File

@@ -10,7 +10,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (token) { if (token) {
router.replace("/tasks"); router.replace("/dashboard");
} else { } else {
router.replace("/login"); router.replace("/login");
} }

View File

@@ -10,6 +10,8 @@ import {
deleteProject, deleteProject,
Project, Project,
} from "@/lib/api"; } from "@/lib/api";
import PageActionBar from "@/components/features/PageActionBar";
import DeleteIconButton from "@/components/features/DeleteIconButton";
export default function ProjectsPage() { export default function ProjectsPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
@@ -20,7 +22,6 @@ export default function ProjectsPage() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Form state
const [formName, setFormName] = useState(""); const [formName, setFormName] = useState("");
const [formDesc, setFormDesc] = useState(""); const [formDesc, setFormDesc] = useState("");
const [formColor, setFormColor] = useState("#3B82F6"); const [formColor, setFormColor] = useState("#3B82F6");
@@ -96,18 +97,14 @@ export default function ProjectsPage() {
return ( return (
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0"> <div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
{/* Header */} <PageActionBar
<div className="flex items-center justify-between"> title={t("nav.projects")}
<h1 className="text-xl font-bold dark:text-white">{t("nav.projects")}</h1> showAdd
<button onToggleAdd={() => setShowForm(!showForm)}
onClick={() => setShowForm(!showForm)} addOpen={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]" t={t}
> />
{showForm ? t("tasks.form.cancel") : `+ ${t("projects.add")}`}
</button>
</div>
{/* Error */}
{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"> <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} {error}
@@ -115,7 +112,6 @@ export default function ProjectsPage() {
</div> </div>
)} )}
{/* Create form */}
{showForm && ( {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"> <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> <div>
@@ -179,7 +175,6 @@ export default function ProjectsPage() {
</form> </form>
)} )}
{/* Projects list */}
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /> <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>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} /> <div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
<button <DeleteIconButton
onClick={() => handleDelete(project.id)} 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" label={t("tasks.delete")}
title={t("tasks.delete")} size="sm"
> />
<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>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,31 @@ import { useAuth } from "@/lib/auth";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
import { useTranslation, LOCALES } from "@/lib/i18n"; import { useTranslation, LOCALES } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n"; import type { Locale } from "@/lib/i18n";
import type { Group } from "@/lib/api";
import { webauthnRegisterOptions, webauthnRegisterVerify, webauthnGetDevices, webauthnDeleteDevice } from "@/lib/api";
import Link from "next/link";
type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini";
const ALL_WIDGETS: { key: WidgetType; label: string }[] = [
{ key: "current_tasks", label: "Aktualni ukoly" },
{ key: "category_time", label: "Aktivni kategorie" },
{ key: "today_progress", label: "Dnesni pokrok" },
{ key: "next_task", label: "Pristi ukol" },
{ key: "motivace", label: "Motivace" },
{ key: "calendar_mini", label: "Mini kalendar" },
];
const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"];
interface GroupSetting {
from: string;
to: string;
days: number[];
locationName: string;
gps: string;
radius: number;
}
export default function SettingsPage() { export default function SettingsPage() {
const { token, user, logout } = useAuth(); const { token, user, logout } = useAuth();
@@ -19,6 +44,19 @@ export default function SettingsPage() {
dailySummary: false, dailySummary: false,
}); });
const [saved, setSaved] = useState(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);
const [widgetEnabled, setWidgetEnabled] = useState<Record<WidgetType, boolean>>(() => {
const defaults: Record<WidgetType, boolean> = { current_tasks: true, category_time: true, today_progress: true, next_task: false, motivace: false, calendar_mini: false };
return defaults;
});
const [inactivityTimeout, setInactivityTimeout] = useState(5);
const [widgetSaved, setWidgetSaved] = useState(false);
const [biometricDevices, setBiometricDevices] = useState<Array<{ id: string; device_name: string; created_at: string }>>([]);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLoading, setBiometricLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -34,9 +72,202 @@ export default function SettingsPage() {
// ignore // ignore
} }
} }
const savedWidgets = localStorage.getItem("widget_config");
if (savedWidgets) {
try {
const cfg = JSON.parse(savedWidgets);
if (Array.isArray(cfg.enabled)) {
const map: Record<WidgetType, boolean> = { current_tasks: false, category_time: false, today_progress: false, next_task: false, motivace: false, calendar_mini: false };
for (const w of cfg.enabled) map[w as WidgetType] = true;
setWidgetEnabled(map);
}
if (cfg.inactivityTimeout > 0) setInactivityTimeout(cfg.inactivityTimeout);
} catch {
// ignore
}
}
} }
}, [token, router]); }, [token, router]);
// Check biometric availability and load devices
useEffect(() => {
if (typeof window !== "undefined" && window.PublicKeyCredential) {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.()
.then(available => setBiometricAvailable(available))
.catch(() => {});
}
}, []);
useEffect(() => {
if (token && user?.id) {
webauthnGetDevices(token, user.id).then(res => setBiometricDevices(res.data || [])).catch(() => {});
}
}, [token, user]);
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);
}
async function addBiometricDevice() {
if (!token || !user?.id) return;
setBiometricLoading(true);
try {
const optionsRes = await webauthnRegisterOptions(token, user.id);
const options = optionsRes.data;
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge as string),
rp: options.rp as { name: string; id: string },
user: {
id: base64urlToBuffer((options.user as { id: string }).id),
name: (options.user as { name: string }).name,
displayName: (options.user as { displayName: string }).displayName,
},
pubKeyCredParams: (options.pubKeyCredParams as Array<{ alg: number; type: string }>).map(p => ({
alg: p.alg,
type: p.type as PublicKeyCredentialType,
})),
authenticatorSelection: {
authenticatorAttachment: "platform" as AuthenticatorAttachment,
userVerification: "required" as UserVerificationRequirement,
},
timeout: 60000,
},
}) as PublicKeyCredential;
if (!credential) throw new Error("Registrace selhala");
const credentialId = bufferToBase64url(credential.rawId);
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = bufferToBase64url(response.getPublicKey?.() || response.attestationObject);
// Detect device name
const ua = navigator.userAgent;
let deviceName = "Biometric";
if (/iPhone|iPad/.test(ua)) deviceName = "Face ID (iOS)";
else if (/Mac/.test(ua)) deviceName = "Touch ID (Mac)";
else if (/Android/.test(ua)) deviceName = "Otisk prstu (Android)";
else if (/Windows/.test(ua)) deviceName = "Windows Hello";
await webauthnRegisterVerify(token, {
user_id: user.id,
credential_id: credentialId,
public_key: publicKey,
device_name: deviceName,
});
// Save email for biometric login
localStorage.setItem("taskteam_biometric_email", user.email || "");
// Refresh device list
const devRes = await webauthnGetDevices(token, user.id);
setBiometricDevices(devRes.data || []);
} catch (err) {
console.error("WebAuthn registration error:", err);
alert(err instanceof Error ? err.message : "Registrace biometrie selhala");
} finally {
setBiometricLoading(false);
}
}
async function removeBiometricDevice(deviceId: string) {
if (!token || !user?.id) return;
if (!confirm("Opravdu odebrat toto zarizeni?")) return;
try {
await webauthnDeleteDevice(token, deviceId);
setBiometricDevices(prev => prev.filter(d => d.id !== deviceId));
} catch {
alert("Nepodarilo se odebrat zarizeni");
}
}
function toggleWidget(key: WidgetType) {
setWidgetEnabled(prev => ({ ...prev, [key]: !prev[key] }));
}
function saveWidgetConfig() {
const enabled = ALL_WIDGETS.filter(w => widgetEnabled[w.key]).map(w => w.key);
const config = { enabled, inactivityTimeout };
localStorage.setItem("widget_config", JSON.stringify(config));
setWidgetSaved(true);
setTimeout(() => setWidgetSaved(false), 2000);
}
function handleSave() { function handleSave() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications)); localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
@@ -70,6 +301,52 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Biometric auth section */}
{biometricAvailable && (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Biometricke prihlaseni</h2>
{biometricDevices.length > 0 ? (
<div className="space-y-2 mb-4">
{biometricDevices.map(device => (
<div key={device.id} className="flex items-center justify-between py-3 px-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-blue-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a48.667 48.667 0 00-1.418 8.773M11.997 3.001A7.5 7.5 0 0014.5 10.5c0 2.887-.543 5.649-1.533 8.19M9.003 3.001a7.5 7.5 0 00-2.497 7.499 48.12 48.12 0 01-.544 5M12 10.5a2.25 2.25 0 10-4.5 0 2.25 2.25 0 004.5 0z" />
</svg>
<div>
<div className="text-sm font-medium">{device.device_name}</div>
<div className="text-xs text-gray-400">
{new Date(device.created_at).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" })}
</div>
</div>
</div>
<button
onClick={() => removeBiometricDevice(device.id)}
className="text-xs text-red-500 hover:text-red-600 font-medium px-2 py-1"
>
Odebrat
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 mb-4">Zadne zarizeni zatim nebylo pridano.</p>
)}
<button
onClick={addBiometricDevice}
disabled={biometricLoading}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 transition-colors disabled:opacity-50 text-sm font-medium"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-blue-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a48.667 48.667 0 00-1.418 8.773M12 10.5a2.25 2.25 0 10-4.5 0 2.25 2.25 0 004.5 0z" />
</svg>
{biometricLoading ? "Registruji..." : "Pridat Face ID / otisk prstu"}
</button>
</div>
)}
{/* Install section */} {/* Install section */}
<section id="install" className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-200 dark:border-gray-700"> <section id="install" className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
<h2 className="font-semibold mb-3">{t("settings.install") || "Instalace"}</h2> <h2 className="font-semibold mb-3">{t("settings.install") || "Instalace"}</h2>
@@ -193,6 +470,172 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Widget & Lock Screen settings */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Widgety & Zamykaci obrazovka</h2>
<div className="space-y-1">
{ALL_WIDGETS.map(w => (
<div key={w.key} className="flex items-center justify-between py-3">
<span className="text-sm font-medium">{w.label}</span>
<button
onClick={() => toggleWidget(w.key)}
className={`relative w-12 h-7 rounded-full transition-colors ${
widgetEnabled[w.key] ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
aria-label={w.label}
>
<div className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
widgetEnabled[w.key] ? "translate-x-5" : "translate-x-0.5"
}`} />
</button>
</div>
))}
</div>
{/* Inactivity timeout */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Neaktivita (min)</span>
<span className="text-sm text-muted">{inactivityTimeout} min</span>
</div>
<input
type="range"
min={1}
max={30}
step={1}
value={inactivityTimeout}
onChange={e => setInactivityTimeout(Number(e.target.value))}
className="w-full"
/>
<p className="text-xs text-muted mt-1">Po teto dobe neaktivity se zobrazi zamykaci obrazovka (pouze v PWA rezimu).</p>
</div>
{/* Preview lockscreen button */}
<Link
href="/lockscreen"
className="mt-4 w-full py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm font-medium text-center block hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
Nahled zamykaci obrazovky
</Link>
{/* Save widget config */}
<button
onClick={saveWidgetConfig}
className={`mt-3 w-full py-2.5 rounded-xl font-medium transition-all text-sm ${
widgetSaved ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{widgetSaved ? "Ulozeno \u2713" : "Ulozit nastaveni widgetu"}
</button>
</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 */} {/* Save button */}
<button <button
onClick={handleSave} onClick={handleSave}
@@ -235,3 +678,20 @@ export default function SettingsPage() {
</div> </div>
); );
} }
// --- WebAuthn buffer helpers ---
function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4));
const binary = atob(base64 + pad);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

View File

@@ -17,6 +17,9 @@ import {
sendCollabRequest, sendCollabRequest,
searchUsers, searchUsers,
} from "@/lib/api"; } from "@/lib/api";
import CollabBackButton from "@/components/features/CollabBackButton";
import CollabActionButtons from "@/components/features/CollabActionButtons";
import IconButton from "@/components/features/IconButton";
interface UserResult { interface UserResult {
id: string; id: string;
@@ -130,7 +133,6 @@ export default function CollaboratePage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
// Forms
const [showAssignSearch, setShowAssignSearch] = useState(false); const [showAssignSearch, setShowAssignSearch] = useState(false);
const [showTransferSearch, setShowTransferSearch] = useState(false); const [showTransferSearch, setShowTransferSearch] = useState(false);
const [transferMessage, setTransferMessage] = useState(""); const [transferMessage, setTransferMessage] = useState("");
@@ -152,19 +154,15 @@ export default function CollaboratePage() {
setSubtasks(subtasksData.data || []); setSubtasks(subtasksData.data || []);
setHistory(historyData.data || []); setHistory(historyData.data || []);
// Load assignee details
const assignedIds: string[] = taskData.assigned_to || []; const assignedIds: string[] = taskData.assigned_to || [];
if (assignedIds.length > 0) { if (assignedIds.length > 0) {
try { try {
const usersRes = await searchUsers(token, ""); 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>(); const knownUsers = new Map<string, UserResult>();
(historyData.data || []).forEach((h: CollabRequest) => { (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.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 }); 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)); (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 })); setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }));
} catch { } catch {
@@ -316,18 +314,13 @@ export default function CollaboratePage() {
}; };
return ( 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 */} {/* Header */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <CollabBackButton
onClick={() => router.push(`/tasks/${id}`)} 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" label={t("common.back")}
> />
<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>
<h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1> <h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1>
</div> </div>
@@ -367,47 +360,20 @@ export default function CollaboratePage() {
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p> <p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
)} )}
{/* Action buttons */} {/* Action buttons - icon only with tooltips */}
<div className="flex flex-wrap gap-2"> <CollabActionButtons
<button onAssign={() => {
onClick={() => { setShowAssignSearch(!showAssignSearch);
setShowAssignSearch(!showAssignSearch); setShowTransferSearch(false);
setShowTransferSearch(false); }}
}} onTransfer={() => {
disabled={actionLoading} setShowTransferSearch(!showTransferSearch);
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" setShowAssignSearch(false);
> }}
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> onClaim={handleClaim}
<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" /> disabled={actionLoading}
</svg> t={t}
{t("collab.assign")} />
</button>
<button
onClick={() => {
setShowTransferSearch(!showTransferSearch);
setShowAssignSearch(false);
}}
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>
{/* Assign search dropdown */} {/* Assign search dropdown */}
{showAssignSearch && ( {showAssignSearch && (
@@ -452,7 +418,6 @@ export default function CollaboratePage() {
)} )}
</div> </div>
{/* Progress bar */}
{subtasksTotal > 0 && ( {subtasksTotal > 0 && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3"> <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
<div <div
@@ -462,10 +427,9 @@ export default function CollaboratePage() {
</div> </div>
)} )}
{/* Subtask list */}
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{subtasks.map((sub) => { {subtasks.map((sub) => {
const isDone = sub.status === "done" || sub.status === "completed"; const subDone = sub.status === "done" || sub.status === "completed";
return ( return (
<div <div
key={sub.id} key={sub.id}
@@ -474,18 +438,18 @@ export default function CollaboratePage() {
<button <button
onClick={() => handleToggleSubtask(sub)} onClick={() => handleToggleSubtask(sub)}
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${ 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" ? "bg-green-500 border-green-500 text-white"
: "border-gray-300 dark:border-gray-600 hover:border-green-400" : "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}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
)} )}
</button> </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} {sub.title}
</span> </span>
{sub.assignee_name && ( {sub.assignee_name && (
@@ -495,7 +459,8 @@ export default function CollaboratePage() {
)} )}
<button <button
onClick={() => handleDeleteSubtask(sub.id)} 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}> <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" /> <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> <p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
)} )}
{/* Add subtask form */}
{showSubtaskForm ? ( {showSubtaskForm ? (
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3"> <div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<input <input
@@ -543,35 +507,47 @@ export default function CollaboratePage() {
/> />
)} )}
<div className="flex gap-2"> <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} onClick={handleAddSubtask}
disabled={!newSubtaskTitle.trim() || actionLoading} 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" variant="primary"
> size="md"
{t("collab.addBtn")} />
</button> <IconButton
<button 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={() => { onClick={() => {
setShowSubtaskForm(false); setShowSubtaskForm(false);
setNewSubtaskTitle(""); setNewSubtaskTitle("");
setSubtaskAssignee(null); setSubtaskAssignee(null);
}} }}
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400" variant="default"
> size="md"
{t("tasks.form.cancel")} />
</button>
</div> </div>
</div> </div>
) : ( ) : (
<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="M12 4v16m8-8H4" />
</svg>
}
label={t("collab.addSubtask")}
onClick={() => setShowSubtaskForm(true)} onClick={() => setShowSubtaskForm(true)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium" variant="primary"
> size="md"
<svg className="w-4 h-4" 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>
)} )}
</div> </div>

View File

@@ -18,6 +18,8 @@ import {
import TaskForm from "@/components/TaskForm"; import TaskForm from "@/components/TaskForm";
import InviteModal from "@/components/InviteModal"; import InviteModal from "@/components/InviteModal";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
import TaskDetailActions from "@/components/features/TaskDetailActions";
import InlineEditField from "@/components/features/InlineEditField";
function isDone(status: string): boolean { function isDone(status: string): boolean {
return status === "done" || status === "completed"; return status === "done" || status === "completed";
@@ -70,7 +72,6 @@ export default function TaskDetailPage() {
setTask(taskData); setTask(taskData);
setGroups(groupsData.data || []); setGroups(groupsData.data || []);
setSubtasks(subtasksData.data || []); setSubtasks(subtasksData.data || []);
// Load assignee names
const assigned: string[] = taskData.assigned_to || []; const assigned: string[] = taskData.assigned_to || [];
if (assigned.length > 0) { if (assigned.length > 0) {
try { try {
@@ -106,6 +107,16 @@ export default function TaskDetailPage() {
loadTask(); 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() { async function handleDelete() {
if (!token || !id) return; if (!token || !id) return;
if (!confirm(t("tasks.confirmDelete"))) return; if (!confirm(t("tasks.confirmDelete"))) return;
@@ -183,90 +194,46 @@ export default function TaskDetailPage() {
const taskDone = isDone(task.status); const taskDone = isDone(task.status);
return ( return (
<div className="max-w-lg mx-auto space-y-4"> <div className="max-w-lg mx-auto space-y-4 px-4 sm:px-0">
{/* Action bar - all buttons in one compact row */} {/* Action bar - all icon buttons in one compact row */}
<div className="flex items-center gap-2 flex-wrap"> <TaskDetailActions
<button taskDone={taskDone}
onClick={() => router.push("/tasks")} deleting={deleting}
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" onBack={() => router.push("/tasks")}
> onEdit={() => setEditing(true)}
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> onDelete={handleDelete}
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> onToggleStatus={() => handleQuickStatus(taskDone ? "pending" : "done")}
</svg> onInvite={() => setShowInvite(true)}
{t("common.back")} onCollaborate={() => router.push(`/tasks/${id}/collaborate`)}
</button> t={t}
/>
<div className="flex-1" /> {/* Task detail card with inline editing */}
{!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 */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6"> <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 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 && ( {task.group_icon && (
<span className="text-2xl">{task.group_icon}</span> <span className="text-2xl flex-shrink-0">{task.group_icon}</span>
)} )}
<h1 <InlineEditField
className={`text-xl font-bold ${ value={task.title}
taskDone ? "line-through text-muted" : "" onSave={(val) => handleInlineUpdate("title", val)}
}`} className={`text-xl font-bold ${taskDone ? "line-through text-muted" : ""}`}
> />
{task.title}
</h1>
</div> </div>
<StatusBadge status={task.status} size="md" /> <StatusBadge status={task.status} size="md" />
</div> </div>
{task.description && ( <div className="mb-4">
<p className="text-muted mb-4 whitespace-pre-wrap leading-relaxed"> <InlineEditField
{task.description} value={task.description || ""}
</p> 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 className="grid grid-cols-2 gap-3 text-sm">
<div> <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"> <h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("collab.collaboration")} {t("collab.collaboration")}
</h2> </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> </div>
{/* Assigned users */} {/* Assigned users */}

View File

@@ -6,21 +6,33 @@ import { useAuth } from "@/lib/auth";
import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api"; import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import TaskCard from "@/components/TaskCard"; import TaskCard from "@/components/TaskCard";
import GroupSelector from "@/components/GroupSelector";
import TaskModal from "@/components/TaskModal"; import TaskModal from "@/components/TaskModal";
import { useSwipeable } from "react-swipeable"; 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() { export default function TasksPage() {
const { token } = useAuth(); const { token, user } = useAuth();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null); 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 [showForm, setShowForm] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null); const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null);
const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | 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); return groupOrder.indexOf(selectedGroup);
}, [groupOrder, selectedGroup]); }, [groupOrder, selectedGroup]);
const selectedGroupObj = useMemo(
() => (selectedGroup ? groups.find((g) => g.id === selectedGroup) ?? null : null),
[selectedGroup, groups]
);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!token) return; if (!token) return;
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (selectedGroup) params.group_id = selectedGroup; if (selectedGroup) params.group_id = selectedGroup;
if (statusFilter !== "all") params.status = statusFilter; if (selectedStatus) params.status = selectedStatus;
const [tasksRes, groupsRes] = await Promise.all([ const [tasksRes, groupsRes] = await Promise.all([
getTasks(token, Object.keys(params).length > 0 ? params : undefined), getTasks(token, Object.keys(params).length > 0 ? params : undefined),
@@ -53,7 +70,7 @@ export default function TasksPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token, selectedGroup, statusFilter]); }, [token, selectedGroup, selectedStatus]);
useEffect(() => { useEffect(() => {
if (!token) { 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; if (!token) return null;
const userInitial = (user?.name || user?.email || "?").charAt(0).toUpperCase();
return ( return (
<div className="space-y-2 pb-24 sm:pb-8 px-4 sm:px-0"> <div className="pb-24 sm:pb-8">
{/* Group dropdown + Status pills — single compact row */} {/* Single-row sticky filter header — group dropdown left, status pills right */}
<div className="flex items-center gap-3 flex-nowrap"> <div style={{
<div className="flex-shrink-0"> display: "flex", alignItems: "center",
<GroupSelector padding: "0 8px",
groups={groups} position: "sticky", top: 40, zIndex: 40,
selected={selectedGroup} height: 40, maxHeight: 40,
onSelect={setSelectedGroup} 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
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",
}}
>
{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> </div>
<div className="flex gap-1 overflow-x-auto scrollbar-hide flex-1 min-w-0">
{statusOptions.map((opt) => ( {/* 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 <button
key={opt.value} onClick={() => setSelectedStatus(null)}
onClick={() => setStatusFilter(opt.value)} style={{
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-all ${ padding: "3px 10px", borderRadius: 14, height: 28,
statusFilter === opt.value border: "none", cursor: "pointer", whiteSpace: "nowrap",
? "bg-gray-800 text-white dark:bg-white dark:text-gray-900" fontSize: 11, fontWeight: selectedStatus === null ? 700 : 500,
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700" background: selectedStatus === null ? "rgba(29,78,216,0.12)" : "transparent",
}`} color: selectedStatus === null ? "#2563EB" : "var(--muted, #6B7280)",
transition: "all 0.15s ease",
flexShrink: 0,
}}
> >
{opt.label} {t("tasks.all")}
</button> </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>
</div> </div>
{/* Swipeable task list area */} {/* 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 */} {/* Swipe overlay */}
{swipeOverlay && ( {swipeOverlay && (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
@@ -203,6 +315,7 @@ export default function TasksPage() {
key={task.id} key={task.id}
task={task} task={task}
onComplete={handleCompleteTask} onComplete={handleCompleteTask}
onUpdate={loadData}
/> />
))} ))}
</div> </div>

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"; "use client";
import { useState, useEffect, useCallback } from "react"; import CompactHeader from "./features/CompactHeader";
import { useAuth } from "@/lib/auth";
import { useTheme } from "./ThemeProvider";
import { useTranslation } from "@/lib/i18n";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function Header() { export default function Header() {
const { user, logout, token } = useAuth(); return <CompactHeader />;
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>
)}
</>
);
} }

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
export default function InactivityMonitor() {
const router = useRouter();
const pathname = usePathname();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
// Only activate in PWA standalone mode
const isStandalone =
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
if (!isStandalone) return;
// Skip on lockscreen/login pages
if (pathname?.startsWith("/lockscreen") || pathname?.startsWith("/login") || pathname?.startsWith("/register")) return;
// Read timeout from localStorage
let timeoutMinutes = 5;
try {
const stored = localStorage.getItem("widget_config");
if (stored) {
const cfg = JSON.parse(stored);
if (cfg.inactivityTimeout > 0) timeoutMinutes = cfg.inactivityTimeout;
}
} catch { /* ignore */ }
const timeoutMs = timeoutMinutes * 60 * 1000;
function resetTimer() {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
router.push("/lockscreen");
}, timeoutMs);
}
const events = ["mousedown", "mousemove", "keydown", "touchstart", "scroll", "click"];
events.forEach(e => window.addEventListener(e, resetTimer, { passive: true }));
resetTimer();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
events.forEach(e => window.removeEventListener(e, resetTimer));
};
}, [pathname, router]);
return null;
}

View File

@@ -1,51 +1,106 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { Task } from "@/lib/api"; import { Task, updateTask } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import StatusBadge from "./StatusBadge";
import Link from "next/link"; import Link from "next/link";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
interface TaskCardProps { interface TaskCardProps {
task: Task; task: Task;
onComplete?: (taskId: string) => void; onComplete?: (taskId: string) => void;
onAssign?: (taskId: string) => void;
onUpdate?: () => void;
} }
const PRIORITY_COLORS: Record<string, string> = { const STATUS_CYCLE: Task["status"][] = ["pending", "in_progress", "done"];
urgent: "#ef4444",
high: "#f97316",
medium: "#eab308",
low: "#22c55e",
};
function isDone(status: string): boolean { function statusColor(status: string): string {
return status === "done" || status === "completed"; 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, onUpdate }: TaskCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const priorityColor = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium; const { token } = useAuth();
const taskDone = isDone(task.status); const taskDone = task.status === "done";
const [swipeOffset, setSwipeOffset] = useState(0); const [swipeOffset, setSwipeOffset] = useState(0);
const [swiped, setSwiped] = useState(false); const [swiped, setSwiped] = useState(false);
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
// Inline editing state
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Status cycling state
const [currentStatus, setCurrentStatus] = useState(task.status);
// Sync with prop changes
useEffect(() => {
setEditTitle(task.title);
setCurrentStatus(task.status);
}, [task.title, task.status]);
// Focus input when entering edit mode
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
}
}, [editing]);
const SWIPE_THRESHOLD = 120; const SWIPE_THRESHOLD = 120;
const assignees = task.assigned_to || [];
const visibleAssignees = assignees.slice(0, 3);
const groupColor = task.group_color;
const sColor = statusColor(currentStatus);
const swipeHandlers = useSwipeable({ const swipeHandlers = useSwipeable({
onSwiping: (e) => { onSwiping: (e) => {
if (e.dir === "Right" && !taskDone && onComplete) { if (e.dir === "Right" && !taskDone && onComplete) {
const offset = Math.min(e.deltaX, 160); setSwipeOffset(Math.min(e.deltaX, 160));
setSwipeOffset(offset);
} }
}, },
onSwipedRight: (e) => { onSwipedRight: (e) => {
if (e.absX > SWIPE_THRESHOLD && !taskDone && onComplete) { if (e.absX > SWIPE_THRESHOLD && !taskDone && onComplete) {
setSwiped(true); setSwiped(true);
setTimeout(() => { setTimeout(() => { onComplete(task.id); }, 300);
onComplete(task.id);
}, 300);
} else { } else {
setSwipeOffset(0); setSwipeOffset(0);
} }
@@ -61,15 +116,74 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
const showCompleteHint = swipeOffset > 40; const showCompleteHint = swipeOffset > 40;
// Save title edit
const saveTitle = useCallback(async () => {
const trimmed = editTitle.trim();
if (!trimmed || trimmed === task.title || !token) {
setEditTitle(task.title);
setEditing(false);
return;
}
setSaving(true);
try {
await updateTask(token, task.id, { title: trimmed });
setEditing(false);
if (onUpdate) onUpdate();
} catch (err) {
console.error("Failed to update title:", err);
setEditTitle(task.title);
setEditing(false);
} finally {
setSaving(false);
}
}, [editTitle, task.title, task.id, token, onUpdate]);
// Cycle status on dot click
const cycleStatus = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!token) return;
const idx = STATUS_CYCLE.indexOf(currentStatus);
const nextStatus = STATUS_CYCLE[(idx + 1) % STATUS_CYCLE.length];
// Optimistic update
setCurrentStatus(nextStatus);
try {
await updateTask(token, task.id, { status: nextStatus });
if (onUpdate) onUpdate();
} catch (err) {
console.error("Failed to cycle status:", err);
setCurrentStatus(task.status); // revert
}
}, [token, task.id, task.status, currentStatus, onUpdate]);
// Handle title click -> enter edit mode
const handleTitleClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setEditing(true);
}, []);
// Handle keyboard in edit input
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
saveTitle();
}
if (e.key === "Escape") {
setEditTitle(task.title);
setEditing(false);
}
}, [saveTitle, task.title]);
return ( return (
<div className="relative overflow-hidden rounded-lg" ref={cardRef}> <div className="relative overflow-hidden" ref={cardRef} style={{ margin: "0 0 5px" }}>
{/* Swipe background - green complete indicator */} {/* Swipe background */}
{onComplete && !taskDone && ( {onComplete && !taskDone && (
<div <div
className={`absolute inset-0 flex items-center pl-4 rounded-lg transition-colors ${ className={`absolute inset-0 flex items-center pl-4 rounded-xl transition-colors ${
swipeOffset > SWIPE_THRESHOLD swipeOffset > SWIPE_THRESHOLD ? "bg-green-500" : "bg-green-400/80"
? "bg-green-500"
: "bg-green-400/80"
}`} }`}
> >
<div <div
@@ -92,43 +206,251 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none", transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
}} }}
> >
<Link href={`/tasks/${task.id}`} className="block group"> {/* When editing, don't wrap in Link - just show the card with input */}
<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" : ""}`}> {editing ? (
{/* Priority line on left edge */} <div
<div style={{
className="absolute left-0 top-0 bottom-0 w-0.5" padding: "10px 14px",
style={{ backgroundColor: priorityColor }} borderRadius: 10,
/> background: "#13131A",
border: `1px solid ${groupColor ? groupColor + "30" : "#1E1E2E"}`,
borderLeft: `3px solid ${groupColor || "#2A2A3A"}`,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{/* LEFT: editable input */}
<div style={{ flex: 1, minWidth: 0 }}>
<input
ref={inputRef}
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={saveTitle}
onKeyDown={handleKeyDown}
disabled={saving}
style={{
width: "100%",
fontSize: 14,
fontWeight: 500,
color: "#E8E8F0",
background: "#1A1A28",
border: "1px solid #3B82F6",
borderRadius: 6,
padding: "4px 8px",
outline: "none",
boxShadow: "0 0 0 2px rgba(59,130,246,0.3)",
opacity: saving ? 0.6 : 1,
}}
/>
</div>
<div className="pl-3 pr-2.5 py-2 flex items-center gap-2"> {/* RIGHT: avatars + status dot (still interactive) */}
{/* Group icon */} <div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
{task.group_icon && ( <div style={{ display: "flex", alignItems: "center" }}>
<span className="text-base flex-shrink-0 leading-none">{task.group_icon}</span> {visibleAssignees.map((userId, i) => (
)} <div
key={userId}
{/* Title - single line, truncated */} title={userId}
<h3 style={{
className={`text-sm font-medium truncate flex-1 min-w-0 ${ width: 26, height: 26,
taskDone ? "line-through text-muted" : "" borderRadius: "50%",
}`} marginLeft: i > 0 ? -8 : 0,
> border: "2px solid #13131A",
{task.title} background: userColor(userId),
</h3> display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 10, fontWeight: 700, color: "white",
{/* Status badge */} zIndex: 3 - i,
<div className="flex-shrink-0"> position: "relative",
<StatusBadge status={task.status} size="sm" /> flexShrink: 0,
}}
>
{userId.slice(0, 2).toUpperCase()}
</div>
))}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onAssign) onAssign(task.id);
}}
title="Pridat uzivatele"
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> </div>
{/* Priority dot */} {/* Status dot - clickable to cycle */}
<div <div
className="w-2 h-2 rounded-full flex-shrink-0" onClick={cycleStatus}
style={{ backgroundColor: priorityColor }} title={t(`tasks.status.${currentStatus}`) + " (click to change)"}
title={t(`tasks.priority.${task.priority}`)} style={{
width: 14, height: 14,
borderRadius: "50%",
background: sColor,
flexShrink: 0,
boxShadow: `0 0 6px ${sColor}80`,
cursor: "pointer",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "scale(1.3)";
(e.currentTarget as HTMLElement).style.boxShadow = `0 0 10px ${sColor}`;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "scale(1)";
(e.currentTarget as HTMLElement).style.boxShadow = `0 0 6px ${sColor}80`;
}}
/> />
</div> </div>
</div> </div>
</Link> ) : (
/* Normal mode: Link wrapping for navigation on non-interactive areas */
<Link href={`/tasks/${task.id}`} className="block">
<div
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 (clickable for inline edit) + optional due date */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
onClick={handleTitleClick}
style={{
fontSize: 14,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "#E8E8F0",
textDecoration: taskDone ? "line-through" : "none",
opacity: taskDone ? 0.5 : 1,
borderRadius: 4,
padding: "1px 4px",
margin: "-1px -4px",
transition: "background 0.15s ease",
cursor: "text",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(59,130,246,0.1)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
title="Click to edit title"
>
{task.title}
</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>
{/* 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
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="Pridat uzivatele"
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 - clickable to cycle */}
<div
onClick={cycleStatus}
title={t(`tasks.status.${currentStatus}`) + " (click to change)"}
style={{
width: 14, height: 14,
borderRadius: "50%",
background: sColor,
flexShrink: 0,
boxShadow: `0 0 6px ${sColor}80`,
cursor: "pointer",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "scale(1.3)";
(e.currentTarget as HTMLElement).style.boxShadow = `0 0 10px ${sColor}`;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "scale(1)";
(e.currentTarget as HTMLElement).style.boxShadow = `0 0 6px ${sColor}80`;
}}
/>
</div>
</div>
</Link>
)}
</div> </div>
</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

@@ -53,6 +53,39 @@ export function getMe(token: string) {
return apiFetch<{ user: User }>("/api/v1/auth/me", { token }); return apiFetch<{ user: User }>("/api/v1/auth/me", { token });
} }
// WebAuthn
export function webauthnRegisterOptions(token: string, userId: string) {
return apiFetch<{ data: Record<string, unknown> }>("/api/v1/webauthn/register/options", {
method: "POST", token, body: { user_id: userId },
});
}
export function webauthnRegisterVerify(token: string, data: { user_id: string; credential_id: string; public_key: string; device_name: string }) {
return apiFetch<{ data: Record<string, unknown>; status: string }>("/api/v1/webauthn/register/verify", {
method: "POST", token, body: data,
});
}
export function webauthnAuthOptions(email: string) {
return apiFetch<{ data: Record<string, unknown> }>("/api/v1/webauthn/auth/options", {
method: "POST", body: { email },
});
}
export function webauthnAuthVerify(credentialId: string) {
return apiFetch<{ data: { token: string; user: User } }>("/api/v1/webauthn/auth/verify", {
method: "POST", body: { credential_id: credentialId },
});
}
export function webauthnGetDevices(token: string, userId: string) {
return apiFetch<{ data: Array<{ id: string; device_name: string; created_at: string }> }>(`/api/v1/webauthn/devices/${userId}`, { token });
}
export function webauthnDeleteDevice(token: string, deviceId: string) {
return apiFetch<{ status: string }>(`/api/v1/webauthn/devices/${deviceId}`, { method: "DELETE", token });
}
// Tasks // Tasks
export function getTasks(token: string, params?: Record<string, string>) { export function getTasks(token: string, params?: Record<string, string>) {
const qs = params ? "?" + new URLSearchParams(params).toString() : ""; const qs = params ? "?" + new URLSearchParams(params).toString() : "";
@@ -135,12 +168,28 @@ export interface Task {
group_icon: string | null; 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 { export interface Group {
id: string; id: string;
name: string; name: string;
color: string; color: string;
icon: string | null; icon: string | null;
sort_order: number; sort_order: number;
display_name?: string;
time_zones: GroupTimeZone[];
locations: GroupLocation[];
} }
export interface Connector { export interface Connector {

View File

@@ -12,6 +12,7 @@
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20", "@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35", "next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
@@ -559,6 +560,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",

View File

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

View File

@@ -1 +1,48 @@
{"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",
"display_override": ["fullscreen", "standalone"],
"orientation": "portrait",
"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"
}
]
}
]
}

Binary file not shown.