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:
2026-03-30 11:32:08 +00:00
parent f2915b79fa
commit 4ace4d5f7d
7 changed files with 686 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@
"short_name": "Tasks",
"start_url": "/",
"display": "standalone",
"display_override": ["fullscreen", "standalone"],
"orientation": "portrait",
"background_color": "#0A0A0F",
"theme_color": "#1D4ED8",
"icons": [