- 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>
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api";
|
|
import { useTranslation } from "@/lib/i18n";
|
|
import TaskCard from "@/components/TaskCard";
|
|
import TaskModal from "@/components/TaskModal";
|
|
import { useSwipeable } from "react-swipeable";
|
|
|
|
const STATUS_VALUES = ["pending", "in_progress", "done", "cancelled"] as const;
|
|
type StatusValue = (typeof STATUS_VALUES)[number];
|
|
|
|
function statusColor(s: StatusValue | null): string {
|
|
switch (s) {
|
|
case "pending": return "#FBBF24";
|
|
case "in_progress": return "#60A5FA";
|
|
case "done": return "#34D399";
|
|
case "cancelled": return "#9CA3AF";
|
|
default: return "#7A7A9A";
|
|
}
|
|
}
|
|
|
|
export default function TasksPage() {
|
|
const { token, user } = useAuth();
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
|
const [selectedStatus, setSelectedStatus] = useState<StatusValue | null>(null);
|
|
const [groupOpen, setGroupOpen] = useState(false);
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null);
|
|
const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | null>(null);
|
|
|
|
// Build group order: [null (all), group1.id, group2.id, ...]
|
|
const groupOrder = useMemo(() => {
|
|
return [null, ...groups.map((g) => g.id)];
|
|
}, [groups]);
|
|
|
|
const currentGroupIndex = useMemo(() => {
|
|
return groupOrder.indexOf(selectedGroup);
|
|
}, [groupOrder, selectedGroup]);
|
|
|
|
const selectedGroupObj = useMemo(
|
|
() => (selectedGroup ? groups.find((g) => g.id === selectedGroup) ?? null : null),
|
|
[selectedGroup, groups]
|
|
);
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!token) return;
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (selectedGroup) params.group_id = selectedGroup;
|
|
if (selectedStatus) params.status = selectedStatus;
|
|
|
|
const [tasksRes, groupsRes] = await Promise.all([
|
|
getTasks(token, Object.keys(params).length > 0 ? params : undefined),
|
|
getGroups(token),
|
|
]);
|
|
setTasks(tasksRes.data || []);
|
|
setGroups(groupsRes.data || []);
|
|
} catch (err) {
|
|
console.error("Load error:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token, selectedGroup, selectedStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
router.replace("/login");
|
|
return;
|
|
}
|
|
loadData();
|
|
}, [token, router, loadData]);
|
|
|
|
// Navigate to next/previous group via swipe
|
|
const navigateGroup = useCallback(
|
|
(direction: "left" | "right") => {
|
|
if (groupOrder.length <= 1) return;
|
|
const newIndex =
|
|
direction === "left"
|
|
? (currentGroupIndex + 1) % groupOrder.length
|
|
: (currentGroupIndex - 1 + groupOrder.length) % groupOrder.length;
|
|
const newGroupId = groupOrder[newIndex];
|
|
|
|
if (newGroupId === null) {
|
|
setSwipeOverlay({ name: t("tasks.all"), icon: null });
|
|
} else {
|
|
const group = groups.find((g) => g.id === newGroupId);
|
|
setSwipeOverlay({ name: group?.name || "", icon: group?.icon || null });
|
|
}
|
|
setSwipeDirection(direction);
|
|
setSelectedGroup(newGroupId);
|
|
|
|
setTimeout(() => {
|
|
setSwipeOverlay(null);
|
|
setSwipeDirection(null);
|
|
}, 600);
|
|
},
|
|
[groupOrder, currentGroupIndex, groups, t]
|
|
);
|
|
|
|
// Swipe handlers for cycling groups
|
|
const swipeHandlers = useSwipeable({
|
|
onSwipedLeft: () => navigateGroup("left"),
|
|
onSwipedRight: () => navigateGroup("right"),
|
|
trackMouse: false,
|
|
trackTouch: true,
|
|
preventScrollOnSwipe: false,
|
|
delta: 50,
|
|
swipeDuration: 500,
|
|
});
|
|
|
|
async function handleCreateTask(data: Partial<Task>) {
|
|
if (!token) return;
|
|
await createTask(token, data);
|
|
setShowForm(false);
|
|
loadData();
|
|
}
|
|
|
|
async function handleCompleteTask(taskId: string) {
|
|
if (!token) return;
|
|
try {
|
|
await updateTask(token, taskId, { status: "done" });
|
|
loadData();
|
|
} catch (err) {
|
|
console.error("Complete error:", err);
|
|
}
|
|
}
|
|
|
|
if (!token) return null;
|
|
|
|
const userInitial = (user?.name || user?.email || "?").charAt(0).toUpperCase();
|
|
|
|
return (
|
|
<div className="pb-24 sm:pb-8">
|
|
{/* Single-row sticky filter header — group dropdown left, status pills right */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center",
|
|
padding: "0 8px",
|
|
position: "sticky", top: 40, zIndex: 40,
|
|
height: 40, maxHeight: 40,
|
|
background: "var(--background, #fff)",
|
|
borderBottom: "1px solid var(--border, #e5e7eb)",
|
|
flexWrap: "nowrap", overflow: "hidden",
|
|
}}>
|
|
|
|
{/* Groups dropdown — compact, left side */}
|
|
<div style={{ position: "relative", flexShrink: 0 }}>
|
|
<button
|
|
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>
|
|
|
|
{/* Status pills — horizontal scroll, right side */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
flex: 1, overflow: "hidden", marginLeft: 6,
|
|
justifyContent: "flex-end",
|
|
}}
|
|
className="scrollbar-hide"
|
|
>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 3,
|
|
overflowX: "auto", flexShrink: 1,
|
|
}}
|
|
className="scrollbar-hide"
|
|
>
|
|
{/* "All" pill */}
|
|
<button
|
|
onClick={() => setSelectedStatus(null)}
|
|
style={{
|
|
padding: "3px 10px", borderRadius: 14, height: 28,
|
|
border: "none", cursor: "pointer", whiteSpace: "nowrap",
|
|
fontSize: 11, fontWeight: selectedStatus === null ? 700 : 500,
|
|
background: selectedStatus === null ? "rgba(29,78,216,0.12)" : "transparent",
|
|
color: selectedStatus === null ? "#2563EB" : "var(--muted, #6B7280)",
|
|
transition: "all 0.15s ease",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{t("tasks.all")}
|
|
</button>
|
|
{STATUS_VALUES.map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setSelectedStatus(selectedStatus === s ? null : s)}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
padding: "3px 10px", borderRadius: 14, height: 28,
|
|
border: "none", cursor: "pointer", whiteSpace: "nowrap",
|
|
fontSize: 11, fontWeight: selectedStatus === s ? 700 : 500,
|
|
background: selectedStatus === s ? statusColor(s) + "20" : "transparent",
|
|
color: selectedStatus === s ? statusColor(s) : "var(--muted, #6B7280)",
|
|
transition: "all 0.15s ease",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span style={{
|
|
width: 6, height: 6, borderRadius: "50%",
|
|
background: statusColor(s), flexShrink: 0,
|
|
}} />
|
|
{t(`tasks.status.${s}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Swipeable task list area */}
|
|
<div {...swipeHandlers} className="relative min-h-[200px] px-4 sm:px-0 pt-2">
|
|
{/* Swipe overlay */}
|
|
{swipeOverlay && (
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
|
<div
|
|
className={`bg-black/70 dark:bg-white/20 backdrop-blur-md text-white px-6 py-3 rounded-2xl flex items-center gap-3 shadow-2xl ${
|
|
swipeDirection === "left" ? "animate-slideOverlayLeft" : "animate-slideOverlayRight"
|
|
}`}
|
|
>
|
|
{swipeOverlay.icon && (
|
|
<span className="text-2xl">{swipeOverlay.icon}</span>
|
|
)}
|
|
<span className="text-lg font-semibold">{swipeOverlay.name}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Task list */}
|
|
<div
|
|
className={`transition-all duration-300 ease-out ${
|
|
swipeDirection === "left"
|
|
? "animate-slideContentLeft"
|
|
: swipeDirection === "right"
|
|
? "animate-slideContentRight"
|
|
: ""
|
|
}`}
|
|
>
|
|
{loading ? (
|
|
<div className="flex justify-center py-8">
|
|
<div className="animate-spin rounded-full h-7 w-7 border-b-2 border-blue-600" />
|
|
</div>
|
|
) : tasks.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="text-4xl mb-3 opacity-50">☐</div>
|
|
<p className="text-muted text-base font-medium">{t("tasks.noTasks")}</p>
|
|
<p className="text-muted text-sm mt-1">
|
|
{t("tasks.createFirst")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{tasks.map((task) => (
|
|
<TaskCard
|
|
key={task.id}
|
|
task={task}
|
|
onComplete={handleCompleteTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating action button */}
|
|
<button
|
|
onClick={() => setShowForm(true)}
|
|
className="fixed bottom-20 sm:bottom-6 right-4 sm:right-6 w-14 h-14 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white rounded-full shadow-lg hover:shadow-xl active:shadow-md transition-all duration-200 flex items-center justify-center z-40 hover:scale-105 active:scale-95"
|
|
aria-label={t("tasks.add")}
|
|
>
|
|
<svg
|
|
className="w-7 h-7"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2.5}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Add task modal */}
|
|
{showForm && (
|
|
<TaskModal
|
|
groups={groups}
|
|
onSubmit={handleCreateTask}
|
|
onClose={() => setShowForm(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|