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>
This commit is contained in:
2026-03-30 01:13:09 +00:00
parent f9c4ec631c
commit 8cf14dcf59
14 changed files with 875 additions and 200 deletions

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,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,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,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

@@ -1,205 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/auth";
import { useTheme } from "./ThemeProvider";
import { useTranslation } from "@/lib/i18n";
import Link from "next/link";
import { useRouter } from "next/navigation";
import CompactHeader from "./features/CompactHeader";
export default function Header() {
const { user, logout, token } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
const router = useRouter();
const [drawerOpen, setDrawerOpen] = useState(false);
function handleLogout() {
logout();
router.push("/login");
setDrawerOpen(false);
}
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
// Close drawer on escape
useEffect(() => {
if (!drawerOpen) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") closeDrawer();
}
document.addEventListener("keydown", handleKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKey);
document.body.style.overflow = "";
};
}, [drawerOpen, closeDrawer]);
return (
<>
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
<div className="max-w-4xl mx-auto px-4 h-11 flex items-center justify-end gap-2">
{/* Right side only: avatar (no name text) + hamburger */}
{token && user && (
<button
onClick={openDrawer}
className="w-7 h-7 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-bold cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all flex-shrink-0"
aria-label={t("common.menu")}
>
{(user.name || user.email || "?").charAt(0).toUpperCase()}
</button>
)}
<button
onClick={openDrawer}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center flex-shrink-0"
aria-label={t("common.menu")}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</header>
{/* Slide-out drawer */}
{drawerOpen && (
<div className="fixed inset-0 z-[60]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fadeIn"
onClick={closeDrawer}
/>
{/* Drawer panel */}
<div className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-gray-900 shadow-2xl animate-slideInRight flex flex-col">
{/* Close button */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<span className="font-semibold text-lg">{t("common.menu")}</span>
<button
onClick={closeDrawer}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label={t("common.closeMenu")}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* User info - full name + email */}
{token && user && (
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold">
{(user.name || user.email || "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{user.name || t("settings.user")}</p>
<p className="text-xs text-muted truncate">{user.email}</p>
</div>
</div>
</div>
)}
{/* Menu items */}
<div className="p-2 space-y-1 flex-1 overflow-y-auto">
<Link
href="/tasks"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm font-medium">{t("nav.tasks")}</span>
</Link>
<Link
href="/calendar"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">{t("nav.calendar")}</span>
</Link>
<Link
href="/chat"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="text-sm font-medium">{t("nav.chat")}</span>
</Link>
<Link
href="/settings"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm font-medium">{t("nav.settings")}</span>
</Link>
{/* Install link */}
<Link
href="/settings#install"
onClick={closeDrawer}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
>
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="text-sm font-medium">{t("settings.install") || "Instalace"}</span>
</Link>
{/* Theme toggle */}
<button
onClick={() => { toggleTheme(); }}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full text-left min-h-[48px]"
>
{theme === "dark" ? (
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
<span className="text-sm font-medium">
{theme === "dark" ? t("settings.light") : t("settings.dark")}
</span>
</button>
</div>
{/* Logout icon at bottom */}
{token && (
<div className="p-4 border-t border-gray-200 dark:border-gray-800 safe-area-bottom">
<button
onClick={handleLogout}
title={t("auth.logout")}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label={t("auth.logout")}
>
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
)}
</div>
</div>
)}
</>
);
return <CompactHeader />;
}

View File

@@ -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>
);
}