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:
28
api/src/features/ai-briefing.js
Normal file
28
api/src/features/ai-briefing.js
Normal 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;
|
||||||
44
api/src/features/kanban.js
Normal file
44
api/src/features/kanban.js
Normal 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;
|
||||||
72
api/src/features/time-tracking.js
Normal file
72
api/src/features/time-tracking.js
Normal 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;
|
||||||
57
api/src/features/webhooks-outgoing.js
Normal file
57
api/src/features/webhooks-outgoing.js
Normal 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;
|
||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
55
apps/tasks/components/features/CollabActionButtons.tsx
Normal file
55
apps/tasks/components/features/CollabActionButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/tasks/components/features/CollabBackButton.tsx
Normal file
23
apps/tasks/components/features/CollabBackButton.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
apps/tasks/components/features/CompactHeader.tsx
Normal file
190
apps/tasks/components/features/CompactHeader.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/tasks/components/features/DeleteIconButton.tsx
Normal file
24
apps/tasks/components/features/DeleteIconButton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/tasks/components/features/GoalActionButtons.tsx
Normal file
63
apps/tasks/components/features/GoalActionButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/tasks/components/features/IconButton.tsx
Normal file
52
apps/tasks/components/features/IconButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/tasks/components/features/InlineEditField.tsx
Normal file
98
apps/tasks/components/features/InlineEditField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/tasks/components/features/PageActionBar.tsx
Normal file
42
apps/tasks/components/features/PageActionBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/tasks/components/features/TaskDetailActions.tsx
Normal file
125
apps/tasks/components/features/TaskDetailActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user