Files
task-team/apps/tasks/app/settings/page.tsx
Admin 4ace4d5f7d 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>
2026-03-30 11:32:08 +00:00

542 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
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;
to: string;
days: number[];
locationName: string;
gps: string;
radius: number;
}
export default function SettingsPage() {
const { token, user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t, locale, setLocale } = useTranslation();
const router = useRouter();
const [notifications, setNotifications] = useState({
push: true,
email: false,
taskReminders: true,
dailySummary: false,
});
const [saved, setSaved] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
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) {
router.replace("/login");
}
// Load saved preferences
if (typeof window !== "undefined") {
const savedNotifs = localStorage.getItem("taskteam_notifications");
if (savedNotifs) {
try {
setNotifications(JSON.parse(savedNotifs));
} catch {
// 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]);
useEffect(() => {
if (!token) return;
fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(res => {
const data: Group[] = res.data || [];
setGroups(data);
const settings: Record<string, GroupSetting> = {};
for (const g of data) {
const tz = g.time_zones?.[0];
const loc = g.locations?.[0];
settings[g.id] = {
from: tz?.from || "",
to: tz?.to || "",
days: tz?.days || [],
locationName: loc?.name || "",
gps: (loc?.lat != null && loc?.lng != null) ? `${loc.lat}, ${loc.lng}` : "",
radius: loc?.radius_m || 200,
};
}
setGroupSettings(settings);
})
.catch(() => {});
}, [token]);
function toggleGroup(id: string) {
setExpandedGroup(prev => prev === id ? null : id);
}
function updateGroupSetting(groupId: string, key: keyof GroupSetting, value: string | number | number[]) {
setGroupSettings(prev => ({
...prev,
[groupId]: { ...prev[groupId], [key]: value },
}));
}
function toggleDay(groupId: string, day: number) {
const current = groupSettings[groupId]?.days || [];
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
updateGroupSetting(groupId, "days", next);
}
function getCurrentLocation(groupId: string) {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(pos => {
const gps = `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`;
updateGroupSetting(groupId, "gps", gps);
});
}
async function saveGroupSettings(groupId: string) {
const s = groupSettings[groupId] || {} as GroupSetting;
const timeZones = (s.from && s.to) ? [{
days: s.days?.length ? s.days : [0, 1, 2, 3, 4, 5, 6],
from: s.from,
to: s.to,
}] : [];
const gpsParts = (s.gps || "").split(",").map(x => parseFloat(x.trim()));
const lat = gpsParts[0] || null;
const lng = gpsParts[1] || null;
const locations = s.locationName ? [{
name: s.locationName,
lat: isNaN(lat as number) ? null : lat,
lng: isNaN(lng as number) ? null : lng,
radius_m: Number(s.radius) || 200,
}] : [];
await fetch(`/api/v1/groups/${groupId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ time_zones: timeZones, locations }),
});
setSavedGroup(groupId);
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));
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
function handleLogout() {
logout();
router.push("/login");
}
if (!token) return null;
return (
<div className="max-w-lg mx-auto space-y-6 px-4 pb-24 sm:pb-8">
<h1 className="text-xl font-bold">{t("settings.title")}</h1>
{/* Profile section */}
<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">{t("settings.profile")}</h2>
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xl font-bold">
{(user?.name || user?.email || "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-semibold text-lg">{user?.name || t("settings.user")}</p>
<p className="text-sm text-muted truncate">{user?.email}</p>
</div>
</div>
</div>
{/* Install section */}
<section id="install" className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
<h2 className="font-semibold mb-3">{t("settings.install") || "Instalace"}</h2>
<div className="space-y-2">
<a href="https://expo.dev/accounts/it-enterprise/projects/task-team/builds/b31c63a8-0e4f-44d6-80e9-98cb24174037"
target="_blank" rel="noopener"
className="flex items-center gap-3 px-4 py-3 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors">
<span className="text-xl">&#x1F916;</span>
<span>Android APK</span>
<span className="ml-auto text-sm opacity-75">St&#xe1;hnout</span>
</a>
<a href="https://tasks.hasdo.info"
target="_blank" rel="noopener"
className="flex items-center gap-3 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors">
<span className="text-xl">&#x1F310;</span>
<span>PWA Web App</span>
<span className="ml-auto text-sm opacity-75">Otev&#x159;&#xed;t</span>
</a>
<div className="flex items-center gap-3 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-500 rounded-lg">
<span className="text-xl">&#x1F34E;</span>
<span>iOS (App Store)</span>
<span className="ml-auto text-sm">P&#x159;ipravujeme</span>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-500 rounded-lg">
<span className="text-xl">&#x1F4E6;</span>
<span>F-Droid</span>
<span className="ml-auto text-sm">P&#x159;ipravujeme</span>
</div>
</div>
</section>
{/* Appearance */}
<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">{t("settings.appearance")}</h2>
{/* Theme toggle */}
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
{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="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>
) : (
<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>
)}
<span className="text-sm font-medium">
{theme === "dark" ? t("settings.dark") : t("settings.light")}
</span>
</div>
<button
onClick={toggleTheme}
className={`relative w-12 h-7 rounded-full transition-colors ${
theme === "dark" ? "bg-blue-600" : "bg-gray-300"
}`}
aria-label={t("common.toggleTheme")}
>
<div
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
theme === "dark" ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
{/* Language */}
<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">{t("settings.language")}</h2>
<div className="grid grid-cols-2 gap-2">
{LOCALES.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code as Locale)}
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium transition-all ${
locale === lang.code
? "bg-blue-600 text-white shadow-md"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
<span className="text-lg">{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
{/* Notifications */}
<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">{t("settings.notifications")}</h2>
<div className="space-y-1">
{[
{ key: "push" as const, label: t("settings.push") },
{ key: "email" as const, label: t("settings.email") },
{ key: "taskReminders" as const, label: t("settings.taskReminders") },
{ key: "dailySummary" as const, label: t("settings.dailySummary") },
].map((item) => (
<div key={item.key} className="flex items-center justify-between py-3">
<span className="text-sm font-medium">{item.label}</span>
<button
onClick={() =>
setNotifications((prev) => ({
...prev,
[item.key]: !prev[item.key],
}))
}
className={`relative w-12 h-7 rounded-full transition-colors ${
notifications[item.key] ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
aria-label={item.label}
>
<div
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
notifications[item.key] ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
))}
</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 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 12, textTransform: "uppercase", letterSpacing: 1, fontWeight: 600 }}>
Skupiny
</div>
{groups.map(group => (
<div key={group.id} style={{
background: "#13131A", border: `1px solid #2A2A3A`,
borderLeft: `3px solid ${group.color || "#4F46E5"}`,
borderRadius: 12, marginBottom: 8, overflow: "hidden",
}}>
<div onClick={() => toggleGroup(group.id)}
style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<span style={{ fontSize: 18 }}>{group.icon || "📁"}</span>
<span style={{ flex: 1, fontWeight: 500, fontSize: 14, color: "#F0F0F5" }}>
{group.display_name || group.name}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>
{group.time_zones?.[0] ? `${group.time_zones[0].from}${group.time_zones[0].to}` : ""}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>{expandedGroup === group.id ? "▲" : "▼"}</span>
</div>
{expandedGroup === group.id && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #2A2A3A" }}>
{/* CAS AKTIVITY */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Čas aktivity (volitelné)
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="time" value={groupSettings[group.id]?.from || ""}
onChange={e => updateGroupSetting(group.id, "from", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<span style={{ color: "#6B6B85" }}></span>
<input type="time" value={groupSettings[group.id]?.to || ""}
onChange={e => updateGroupSetting(group.id, "to", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
</div>
{/* DNY V TYDNU */}
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => {
const active = (groupSettings[group.id]?.days || []).includes(i);
return (
<button key={i} onClick={() => toggleDay(group.id, i)} style={{
flex: 1, padding: "6px 0", borderRadius: 6, fontSize: 11,
border: `1px solid ${active ? (group.color || "#4F46E5") : "#2A2A3A"}`,
background: active ? `${group.color || "#4F46E5"}20` : "transparent",
color: active ? (group.color || "#4F46E5") : "#6B6B85",
cursor: "pointer",
}}>{d}</button>
);
})}
</div>
</div>
{/* GPS MISTO */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Místo výkonu (volitelné)
</div>
<input
placeholder="Název místa (např. Synagoga, Kancelář...)"
value={groupSettings[group.id]?.locationName || ""}
onChange={e => updateGroupSetting(group.id, "locationName", e.target.value)}
style={{ width: "100%", padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14, marginBottom: 8, boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<input
placeholder="GPS souřadnice (lat, lng)"
value={groupSettings[group.id]?.gps || ""}
onChange={e => updateGroupSetting(group.id, "gps", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<button onClick={() => getCurrentLocation(group.id)}
style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #1D4ED8", background: "#1D4ED820", color: "#60A5FA", cursor: "pointer", fontSize: 12, whiteSpace: "nowrap" }}>
Moje GPS
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
<span style={{ fontSize: 12, color: "#6B6B85" }}>Polomer:</span>
<input type="range" min="50" max="1000" step="50"
value={groupSettings[group.id]?.radius || 200}
onChange={e => updateGroupSetting(group.id, "radius", Number(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: "#9999AA", minWidth: 50 }}>
{groupSettings[group.id]?.radius || 200}m
</span>
</div>
</div>
{/* ULOZIT */}
<button onClick={() => saveGroupSettings(group.id)}
style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 10, background: savedGroup === group.id ? "#16A34A" : "#1D4ED8", color: "white", border: "none", cursor: "pointer", fontSize: 14, fontWeight: 500, transition: "background 0.2s" }}>
{savedGroup === group.id ? "Uloženo ✓" : "Uložit nastavení skupiny"}
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Save button */}
<button
onClick={handleSave}
className={`w-full py-3 rounded-xl font-medium transition-all ${
saved
? "bg-green-600 text-white"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{saved ? t("settings.saved") : t("settings.save")}
</button>
{/* Logout */}
<button
onClick={handleLogout}
className="w-full py-3 rounded-xl font-medium border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
{t("auth.logout")}
</button>
{/* App info */}
<div className="text-center text-xs text-muted py-4">
<p>{t("common.appName")} {t("common.appVersion")}</p>
</div>
<section className="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border border-red-200 dark:border-red-800 mt-6">
<h2 className="font-semibold text-red-600 dark:text-red-400 mb-2">Smazat ucet</h2>
<p className="text-sm text-red-500 mb-3">Trvale smazat ucet a vsechna data. Tuto akci nelze vratit.</p>
<button onClick={() => {
if (confirm("Opravdu chcete trvale smazat svuj ucet a vsechna data?")) {
const token = localStorage.getItem("taskteam_token");
fetch("/api/v1/auth/delete-account", { method: "DELETE", headers: { Authorization: "Bearer " + token } })
.then(r => r.json())
.then(() => { localStorage.clear(); window.location.href = "/login"; });
}
}} className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium">
Smazat ucet
</button>
</section>
</div>
);
}