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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user