PWA widgets dashboard, lock screen, screensaver with active category
- Dashboard (/dashboard): configurable widget system with 6 widget types (current_tasks, category_time, today_progress, next_task, motivace, calendar_mini) stored in localStorage widget_config - Lock screen (/lockscreen): fullscreen with clock, active group badge, up to 4 current tasks, gradient from group color, swipe/tap to unlock - InactivityMonitor: auto-redirect to lockscreen after configurable timeout (only in PWA standalone/fullscreen mode) - Settings: widget toggle switches, inactivity timeout slider, lockscreen preview - manifest.json: added display_override ["fullscreen","standalone"] + orientation portrait Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
283
apps/tasks/app/dashboard/page.tsx
Normal file
283
apps/tasks/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { getTasks, getGroups, Task, Group } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini";
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"];
|
||||
|
||||
const WIDGET_LABELS: Record<WidgetType, string> = {
|
||||
current_tasks: "Aktualni ukoly",
|
||||
category_time: "Aktivni kategorie",
|
||||
today_progress: "Dnesni pokrok",
|
||||
next_task: "Pristi ukol",
|
||||
motivace: "Motivace",
|
||||
calendar_mini: "Mini kalendar",
|
||||
};
|
||||
|
||||
const MOTIVACE_LIST = [
|
||||
"Kazdy ukol, ktery dokoncis, te priblizuje k cili.",
|
||||
"Male kroky vedou k velkym vysledkum.",
|
||||
"Dnes je skvely den na splneni ukolu.",
|
||||
"Soustred se na to, co muzes ovlivnit.",
|
||||
"Tvoje prace ma smysl. Pokracuj!",
|
||||
"Disciplina premaha talent, kdyz talent nema disciplinu.",
|
||||
"Nejlepsi cas zacit byl vcera. Druhy nejlepsi je ted.",
|
||||
];
|
||||
|
||||
function getActiveGroup(groups: Group[]): Group | null {
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const currentTime = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
for (const group of groups) {
|
||||
for (const tz of group.time_zones || []) {
|
||||
if (tz.days?.length && !tz.days.includes(currentDay)) continue;
|
||||
if (tz.from && tz.to && tz.from <= currentTime && currentTime <= tz.to) return group;
|
||||
}
|
||||
}
|
||||
return groups[0] || null;
|
||||
}
|
||||
|
||||
function getTodayProgress(tasks: Task[]) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const all = tasks.filter(t =>
|
||||
t.due_at?.startsWith(today) || t.scheduled_at?.startsWith(today) ||
|
||||
(t.status !== "cancelled" && t.created_at?.startsWith(today))
|
||||
);
|
||||
const done = all.filter(t => t.status === "done").length;
|
||||
return { done, total: all.length };
|
||||
}
|
||||
|
||||
function getNextTask(tasks: Task[]): Task | null {
|
||||
const now = new Date().toISOString();
|
||||
return tasks
|
||||
.filter(t => t.status !== "done" && t.status !== "cancelled" && t.due_at && t.due_at > now)
|
||||
.sort((a, b) => (a.due_at! > b.due_at! ? 1 : -1))[0] || null;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enabledWidgets, setEnabledWidgets] = useState<WidgetType[]>(DEFAULT_WIDGETS);
|
||||
const [motivace] = useState(() => MOTIVACE_LIST[Math.floor(Math.random() * MOTIVACE_LIST.length)]);
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { router.replace("/login"); return; }
|
||||
try {
|
||||
const stored = localStorage.getItem("widget_config");
|
||||
if (stored) {
|
||||
const cfg = JSON.parse(stored);
|
||||
if (Array.isArray(cfg.enabled) && cfg.enabled.length > 0) {
|
||||
setEnabledWidgets(cfg.enabled);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [token, router]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const [tasksRes, groupsRes] = await Promise.all([getTasks(token), getGroups(token)]);
|
||||
setTasks(tasksRes.data || []);
|
||||
setGroups(groupsRes.data || []);
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Refresh every 2 minutes
|
||||
useEffect(() => {
|
||||
const i = setInterval(loadData, 2 * 60 * 1000);
|
||||
return () => clearInterval(i);
|
||||
}, [loadData]);
|
||||
|
||||
// Update clock every minute
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 60000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const activeGroup = getActiveGroup(groups);
|
||||
const activeTasks = tasks.filter(t => t.status === "pending" || t.status === "in_progress");
|
||||
const progress = getTodayProgress(tasks);
|
||||
const nextTask = getNextTask(tasks);
|
||||
const acColor = activeGroup?.color || "#4F46E5";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
|
||||
function renderWidget(wType: WidgetType) {
|
||||
switch (wType) {
|
||||
case "current_tasks":
|
||||
return (
|
||||
<div key="current_tasks" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800" style={{ borderLeftColor: acColor, borderLeftWidth: 3 }}>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider">Aktualni ukoly</span>
|
||||
<Link href="/tasks" className="text-xs" style={{ color: acColor }}>Zobrazit vse →</Link>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-sm text-gray-400">Nacitam...</div>
|
||||
) : activeTasks.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 text-center py-3">Zadne aktivni ukoly</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeTasks.slice(0, 5).map(task => (
|
||||
<div key={task.id} className="flex items-center gap-2.5">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: task.status === "in_progress" ? "#60A5FA" : "#FBBF24" }} />
|
||||
<span className="text-sm dark:text-[#F0F0F5] text-gray-800 truncate flex-1">{task.title}</span>
|
||||
{task.group_icon && <span className="text-sm">{task.group_icon}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "category_time":
|
||||
if (!activeGroup) return null;
|
||||
return (
|
||||
<div key="category_time" className="rounded-2xl p-4 border" style={{ background: `linear-gradient(135deg, ${acColor}15, transparent)`, borderColor: `${acColor}30` }}>
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Aktivni kategorie</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{ background: `${acColor}20` }}>
|
||||
{activeGroup.icon || "\uD83D\uDCC1"}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold dark:text-[#F0F0F5] text-gray-900">{activeGroup.display_name || activeGroup.name}</div>
|
||||
{activeGroup.time_zones?.[0] && (
|
||||
<div className="text-sm mt-0.5" style={{ color: acColor }}>
|
||||
{activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "today_progress":
|
||||
return (
|
||||
<div key="today_progress" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Dnesni pokrok</div>
|
||||
<div className="flex items-baseline gap-2 mb-2.5">
|
||||
<span className="text-3xl font-extrabold text-emerald-400">{progress.done}</span>
|
||||
<span className="text-base text-gray-500">/ {progress.total} ukolu</span>
|
||||
</div>
|
||||
{progress.total > 0 && (
|
||||
<div className="h-2 bg-gray-200 dark:bg-[#2A2A3A] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.round((progress.done / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{progress.total === 0 && (
|
||||
<div className="text-sm text-gray-400">Zadne ukoly na dnes</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "next_task":
|
||||
return (
|
||||
<div key="next_task" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Pristi ukol</div>
|
||||
{nextTask ? (
|
||||
<div>
|
||||
<div className="text-[15px] font-semibold dark:text-[#F0F0F5] text-gray-900 mb-1">{nextTask.title}</div>
|
||||
{nextTask.due_at && (
|
||||
<div className="text-sm text-amber-400">
|
||||
{new Date(nextTask.due_at).toLocaleString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">Zadne nadchazejici ukoly</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "motivace":
|
||||
return (
|
||||
<div key="motivace" className="rounded-2xl p-4 border" style={{ background: "linear-gradient(135deg, #4F46E510, transparent)", borderColor: "#4F46E530" }}>
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-2">Motivace</div>
|
||||
<div className="text-[15px] text-gray-600 dark:text-[#C7C7D9] leading-relaxed">{motivace}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "calendar_mini": {
|
||||
const DAYS = ["Po", "Ut", "St", "Ct", "Pa", "So", "Ne"];
|
||||
return (
|
||||
<div key="calendar_mini" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Tento tyden</div>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
const dow = (d.getDay() + 6) % 7; // Mon=0
|
||||
d.setDate(d.getDate() + (i - dow));
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
const isToday = dateStr === now.toISOString().slice(0, 10);
|
||||
const taskCount = tasks.filter(t => t.due_at?.startsWith(dateStr) || t.scheduled_at?.startsWith(dateStr)).length;
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] text-gray-500 dark:text-[#6B6B85]">{DAYS[i]}</span>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-[13px] ${
|
||||
isToday ? "bg-blue-600 text-white font-bold" : taskCount > 0 ? "bg-blue-600/10 dark:text-[#F0F0F5] text-gray-800" : "border border-gray-200 dark:border-[#2A2A3A] dark:text-[#F0F0F5] text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{d.getDate()}
|
||||
</div>
|
||||
{taskCount > 0 && <div className="w-1 h-1 rounded-full bg-blue-400" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 pb-24">
|
||||
{/* Header with time */}
|
||||
<div className="flex justify-between items-center py-4 pb-5">
|
||||
<div>
|
||||
<div className="text-3xl font-extrabold dark:text-[#F0F0F5] text-gray-900 leading-none">{timeStr}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-[#6B6B85] mt-1 capitalize">
|
||||
{now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/lockscreen" className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-[#13131A] flex items-center justify-center border border-gray-200 dark:border-gray-800" title="Zamykaci obrazovka">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} className="text-gray-500 dark:text-[#6B6B85]">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link href="/settings" className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-[#13131A] flex items-center justify-center border border-gray-200 dark:border-gray-800">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} className="text-gray-500 dark:text-[#6B6B85]">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widgets */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{enabledWidgets.map(wType => renderWidget(wType))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import ThemeProvider from "@/components/ThemeProvider";
|
||||
import AuthProvider from "@/components/AuthProvider";
|
||||
import Header from "@/components/Header";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import InactivityMonitor from "@/components/InactivityMonitor";
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -44,6 +45,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
</main>
|
||||
<BottomNav />
|
||||
<InactivityMonitor />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
|
||||
239
apps/tasks/app/lockscreen/page.tsx
Normal file
239
apps/tasks/app/lockscreen/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { getTasks, getGroups, Task, Group } from "@/lib/api";
|
||||
|
||||
function getActiveGroup(groups: Group[]): Group | null {
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const currentTime = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
for (const group of groups) {
|
||||
for (const tz of group.time_zones || []) {
|
||||
if (tz.days?.length && !tz.days.includes(currentDay)) continue;
|
||||
if (tz.from && tz.to && tz.from <= currentTime && currentTime <= tz.to) return group;
|
||||
}
|
||||
}
|
||||
return groups[0] || null;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!r) return "29, 78, 216";
|
||||
return `${parseInt(r[1], 16)}, ${parseInt(r[2], 16)}, ${parseInt(r[3], 16)}`;
|
||||
}
|
||||
|
||||
export default function LockScreen() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [swipeStart, setSwipeStart] = useState<number | null>(null);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const [tasksRes, groupsRes] = await Promise.all([getTasks(token), getGroups(token)]);
|
||||
setTasks(tasksRes.data || []);
|
||||
setGroups(groupsRes.data || []);
|
||||
} catch { /* ignore */ }
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Reload data every 5 minutes
|
||||
useEffect(() => {
|
||||
const i = setInterval(loadData, 5 * 60 * 1000);
|
||||
return () => clearInterval(i);
|
||||
}, [loadData]);
|
||||
|
||||
function handleUnlock() {
|
||||
router.push("/tasks");
|
||||
}
|
||||
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
setSwipeStart(e.touches[0].clientY);
|
||||
setSwipeOffset(0);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: React.TouchEvent) {
|
||||
if (swipeStart === null) return;
|
||||
const delta = swipeStart - e.touches[0].clientY;
|
||||
if (delta > 0) setSwipeOffset(Math.min(delta, 150));
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (swipeOffset > 80) handleUnlock();
|
||||
setSwipeStart(null);
|
||||
setSwipeOffset(0);
|
||||
}
|
||||
|
||||
const activeGroup = getActiveGroup(groups);
|
||||
const activeTasks = tasks
|
||||
.filter(t => t.status === "pending" || t.status === "in_progress")
|
||||
.slice(0, 4);
|
||||
const color = activeGroup?.color || "#1D4ED8";
|
||||
const rgb = hexToRgb(color);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
const seconds = pad(now.getSeconds());
|
||||
const dateStr = now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" });
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
background: `linear-gradient(160deg, rgba(${rgb}, 0.35) 0%, rgba(${rgb}, 0.08) 30%, #0A0A0F 60%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
transform: swipeOffset > 0 ? `translateY(-${swipeOffset * 0.3}px)` : undefined,
|
||||
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={handleUnlock}
|
||||
>
|
||||
{/* Ambient glow */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
top: -100,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 400,
|
||||
height: 400,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, rgba(${rgb}, 0.15) 0%, transparent 70%)`,
|
||||
pointerEvents: "none",
|
||||
}} />
|
||||
|
||||
{/* Clock section */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", paddingTop: 40 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
|
||||
<span style={{ fontSize: 88, fontWeight: 200, color: "#FFFFFF", letterSpacing: -4, lineHeight: 1, fontFamily: "system-ui" }}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<span style={{ fontSize: 28, fontWeight: 200, color: "rgba(255,255,255,0.4)" }}>
|
||||
{seconds}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 17, color: "rgba(255,255,255,0.5)", marginTop: 8, textTransform: "capitalize" }}>
|
||||
{dateStr}
|
||||
</div>
|
||||
|
||||
{/* Active group badge */}
|
||||
{activeGroup && (
|
||||
<div style={{
|
||||
marginTop: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
borderRadius: 20,
|
||||
padding: "14px 24px",
|
||||
backdropFilter: "blur(20px)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
}}>
|
||||
<span style={{ fontSize: 32 }}>{activeGroup.icon || "\uD83D\uDCC1"}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 19, fontWeight: 600, color: "#FFFFFF", letterSpacing: 0.3 }}>
|
||||
{activeGroup.display_name || activeGroup.name}
|
||||
</div>
|
||||
{activeGroup.time_zones?.[0] && (
|
||||
<div style={{ fontSize: 13, color, marginTop: 3, opacity: 0.9 }}>
|
||||
{activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks panel */}
|
||||
{activeTasks.length > 0 && (
|
||||
<div style={{ padding: "0 20px 24px" }}>
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
borderRadius: 24,
|
||||
padding: "16px 20px",
|
||||
backdropFilter: "blur(20px)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: "rgba(255,255,255,0.35)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: 600,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
\u00DAkoly ({activeTasks.length})
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{activeTasks.map(task => (
|
||||
<div key={task.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: task.status === "in_progress" ? "#60A5FA" : "#FBBF24",
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 6px ${task.status === "in_progress" ? "#60A5FA" : "#FBBF24"}80`,
|
||||
}} />
|
||||
<span style={{
|
||||
fontSize: 15,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.group_icon && <span style={{ fontSize: 14 }}>{task.group_icon}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unlock indicator */}
|
||||
<div style={{
|
||||
paddingBottom: 44,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background: "rgba(255,255,255,0.25)",
|
||||
}} />
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
Klepn\u011Bte nebo p\u0159eje\u010Fte nahoru
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
router.replace("/tasks");
|
||||
router.replace("/dashboard");
|
||||
} else {
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,20 @@ import { useTheme } from "@/components/ThemeProvider";
|
||||
import { useTranslation, LOCALES } from "@/lib/i18n";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import type { Group } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini";
|
||||
|
||||
const ALL_WIDGETS: { key: WidgetType; label: string }[] = [
|
||||
{ key: "current_tasks", label: "Aktualni ukoly" },
|
||||
{ key: "category_time", label: "Aktivni kategorie" },
|
||||
{ key: "today_progress", label: "Dnesni pokrok" },
|
||||
{ key: "next_task", label: "Pristi ukol" },
|
||||
{ key: "motivace", label: "Motivace" },
|
||||
{ key: "calendar_mini", label: "Mini kalendar" },
|
||||
];
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"];
|
||||
|
||||
interface GroupSetting {
|
||||
from: string;
|
||||
@@ -33,6 +47,12 @@ export default function SettingsPage() {
|
||||
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const [savedGroup, setSavedGroup] = useState<string | null>(null);
|
||||
const [widgetEnabled, setWidgetEnabled] = useState<Record<WidgetType, boolean>>(() => {
|
||||
const defaults: Record<WidgetType, boolean> = { current_tasks: true, category_time: true, today_progress: true, next_task: false, motivace: false, calendar_mini: false };
|
||||
return defaults;
|
||||
});
|
||||
const [inactivityTimeout, setInactivityTimeout] = useState(5);
|
||||
const [widgetSaved, setWidgetSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -48,6 +68,20 @@ export default function SettingsPage() {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const savedWidgets = localStorage.getItem("widget_config");
|
||||
if (savedWidgets) {
|
||||
try {
|
||||
const cfg = JSON.parse(savedWidgets);
|
||||
if (Array.isArray(cfg.enabled)) {
|
||||
const map: Record<WidgetType, boolean> = { current_tasks: false, category_time: false, today_progress: false, next_task: false, motivace: false, calendar_mini: false };
|
||||
for (const w of cfg.enabled) map[w as WidgetType] = true;
|
||||
setWidgetEnabled(map);
|
||||
}
|
||||
if (cfg.inactivityTimeout > 0) setInactivityTimeout(cfg.inactivityTimeout);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [token, router]);
|
||||
|
||||
@@ -129,6 +163,18 @@ export default function SettingsPage() {
|
||||
setTimeout(() => setSavedGroup(null), 2000);
|
||||
}
|
||||
|
||||
function toggleWidget(key: WidgetType) {
|
||||
setWidgetEnabled(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
function saveWidgetConfig() {
|
||||
const enabled = ALL_WIDGETS.filter(w => widgetEnabled[w.key]).map(w => w.key);
|
||||
const config = { enabled, inactivityTimeout };
|
||||
localStorage.setItem("widget_config", JSON.stringify(config));
|
||||
setWidgetSaved(true);
|
||||
setTimeout(() => setWidgetSaved(false), 2000);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
|
||||
@@ -285,6 +331,65 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widget & Lock Screen settings */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Widgety & Zamykaci obrazovka</h2>
|
||||
<div className="space-y-1">
|
||||
{ALL_WIDGETS.map(w => (
|
||||
<div key={w.key} className="flex items-center justify-between py-3">
|
||||
<span className="text-sm font-medium">{w.label}</span>
|
||||
<button
|
||||
onClick={() => toggleWidget(w.key)}
|
||||
className={`relative w-12 h-7 rounded-full transition-colors ${
|
||||
widgetEnabled[w.key] ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
aria-label={w.label}
|
||||
>
|
||||
<div className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
|
||||
widgetEnabled[w.key] ? "translate-x-5" : "translate-x-0.5"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Inactivity timeout */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Neaktivita (min)</span>
|
||||
<span className="text-sm text-muted">{inactivityTimeout} min</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
value={inactivityTimeout}
|
||||
onChange={e => setInactivityTimeout(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">Po teto dobe neaktivity se zobrazi zamykaci obrazovka (pouze v PWA rezimu).</p>
|
||||
</div>
|
||||
|
||||
{/* Preview lockscreen button */}
|
||||
<Link
|
||||
href="/lockscreen"
|
||||
className="mt-4 w-full py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm font-medium text-center block hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Nahled zamykaci obrazovky
|
||||
</Link>
|
||||
|
||||
{/* Save widget config */}
|
||||
<button
|
||||
onClick={saveWidgetConfig}
|
||||
className={`mt-3 w-full py-2.5 rounded-xl font-medium transition-all text-sm ${
|
||||
widgetSaved ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{widgetSaved ? "Ulozeno \u2713" : "Ulozit nastaveni widgetu"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Groups settings */}
|
||||
{groups.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
|
||||
54
apps/tasks/components/InactivityMonitor.tsx
Normal file
54
apps/tasks/components/InactivityMonitor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
export default function InactivityMonitor() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Only activate in PWA standalone mode
|
||||
const isStandalone =
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
window.matchMedia("(display-mode: fullscreen)").matches ||
|
||||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
|
||||
if (!isStandalone) return;
|
||||
|
||||
// Skip on lockscreen/login pages
|
||||
if (pathname?.startsWith("/lockscreen") || pathname?.startsWith("/login") || pathname?.startsWith("/register")) return;
|
||||
|
||||
// Read timeout from localStorage
|
||||
let timeoutMinutes = 5;
|
||||
try {
|
||||
const stored = localStorage.getItem("widget_config");
|
||||
if (stored) {
|
||||
const cfg = JSON.parse(stored);
|
||||
if (cfg.inactivityTimeout > 0) timeoutMinutes = cfg.inactivityTimeout;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
function resetTimer() {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
router.push("/lockscreen");
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const events = ["mousedown", "mousemove", "keydown", "touchstart", "scroll", "click"];
|
||||
events.forEach(e => window.addEventListener(e, resetTimer, { passive: true }));
|
||||
resetTimer();
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
events.forEach(e => window.removeEventListener(e, resetTimer));
|
||||
};
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
"short_name": "Tasks",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0A0A0F",
|
||||
"theme_color": "#1D4ED8",
|
||||
"icons": [
|
||||
|
||||
Reference in New Issue
Block a user