"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 { webauthnRegisterOptions, webauthnRegisterVerify, webauthnGetDevices, webauthnDeleteDevice } 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([]); const [groupSettings, setGroupSettings] = useState>({}); const [expandedGroup, setExpandedGroup] = useState(null); const [savedGroup, setSavedGroup] = useState(null); const [widgetEnabled, setWidgetEnabled] = useState>(() => { const defaults: Record = { 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); const [biometricDevices, setBiometricDevices] = useState>([]); const [biometricAvailable, setBiometricAvailable] = useState(false); const [biometricLoading, setBiometricLoading] = 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 = { 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]); // Check biometric availability and load devices useEffect(() => { if (typeof window !== "undefined" && window.PublicKeyCredential) { window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.() .then(available => setBiometricAvailable(available)) .catch(() => {}); } }, []); useEffect(() => { if (token && user?.id) { webauthnGetDevices(token, user.id).then(res => setBiometricDevices(res.data || [])).catch(() => {}); } }, [token, user]); 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 = {}; 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); } async function addBiometricDevice() { if (!token || !user?.id) return; setBiometricLoading(true); try { const optionsRes = await webauthnRegisterOptions(token, user.id); const options = optionsRes.data; const credential = await navigator.credentials.create({ publicKey: { challenge: base64urlToBuffer(options.challenge as string), rp: options.rp as { name: string; id: string }, user: { id: base64urlToBuffer((options.user as { id: string }).id), name: (options.user as { name: string }).name, displayName: (options.user as { displayName: string }).displayName, }, pubKeyCredParams: (options.pubKeyCredParams as Array<{ alg: number; type: string }>).map(p => ({ alg: p.alg, type: p.type as PublicKeyCredentialType, })), authenticatorSelection: { authenticatorAttachment: "platform" as AuthenticatorAttachment, userVerification: "required" as UserVerificationRequirement, }, timeout: 60000, }, }) as PublicKeyCredential; if (!credential) throw new Error("Registrace selhala"); const credentialId = bufferToBase64url(credential.rawId); const response = credential.response as AuthenticatorAttestationResponse; const publicKey = bufferToBase64url(response.getPublicKey?.() || response.attestationObject); // Detect device name const ua = navigator.userAgent; let deviceName = "Biometric"; if (/iPhone|iPad/.test(ua)) deviceName = "Face ID (iOS)"; else if (/Mac/.test(ua)) deviceName = "Touch ID (Mac)"; else if (/Android/.test(ua)) deviceName = "Otisk prstu (Android)"; else if (/Windows/.test(ua)) deviceName = "Windows Hello"; await webauthnRegisterVerify(token, { user_id: user.id, credential_id: credentialId, public_key: publicKey, device_name: deviceName, }); // Save email for biometric login localStorage.setItem("taskteam_biometric_email", user.email || ""); // Refresh device list const devRes = await webauthnGetDevices(token, user.id); setBiometricDevices(devRes.data || []); } catch (err) { console.error("WebAuthn registration error:", err); alert(err instanceof Error ? err.message : "Registrace biometrie selhala"); } finally { setBiometricLoading(false); } } async function removeBiometricDevice(deviceId: string) { if (!token || !user?.id) return; if (!confirm("Opravdu odebrat toto zarizeni?")) return; try { await webauthnDeleteDevice(token, deviceId); setBiometricDevices(prev => prev.filter(d => d.id !== deviceId)); } catch { alert("Nepodarilo se odebrat zarizeni"); } } 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 (

{t("settings.title")}

{/* Profile section */}

{t("settings.profile")}

{(user?.name || user?.email || "?").charAt(0).toUpperCase()}

{user?.name || t("settings.user")}

{user?.email}

{/* Biometric auth section */} {biometricAvailable && (

Biometricke prihlaseni

{biometricDevices.length > 0 ? (
{biometricDevices.map(device => (
{device.device_name}
{new Date(device.created_at).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" })}
))}
) : (

Zadne zarizeni zatim nebylo pridano.

)}
)} {/* Install section */}

{t("settings.install") || "Instalace"}

🤖 Android APK Stáhnout 🌐 PWA Web App Otevřít
🍎 iOS (App Store) Připravujeme
📦 F-Droid Připravujeme
{/* Appearance */}

{t("settings.appearance")}

{/* Theme toggle */}
{theme === "dark" ? ( ) : ( )} {theme === "dark" ? t("settings.dark") : t("settings.light")}
{/* Language */}

{t("settings.language")}

{LOCALES.map((lang) => ( ))}
{/* Notifications */}

{t("settings.notifications")}

{[ { 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) => (
{item.label}
))}
{/* Widget & Lock Screen settings */}

Widgety & Zamykaci obrazovka

{ALL_WIDGETS.map(w => (
{w.label}
))}
{/* Inactivity timeout */}
Neaktivita (min) {inactivityTimeout} min
setInactivityTimeout(Number(e.target.value))} className="w-full" />

Po teto dobe neaktivity se zobrazi zamykaci obrazovka (pouze v PWA rezimu).

{/* Preview lockscreen button */} Nahled zamykaci obrazovky {/* Save widget config */}
{/* Groups settings */} {groups.length > 0 && (
Skupiny
{groups.map(group => (
toggleGroup(group.id)} style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}> {group.icon || "📁"} {group.display_name || group.name} {group.time_zones?.[0] ? `${group.time_zones[0].from}–${group.time_zones[0].to}` : ""} {expandedGroup === group.id ? "▲" : "▼"}
{expandedGroup === group.id && (
{/* CAS AKTIVITY */}
Čas aktivity (volitelné)
updateGroupSetting(group.id, "from", e.target.value)} style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }} /> updateGroupSetting(group.id, "to", e.target.value)} style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }} />
{/* DNY V TYDNU */}
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => { const active = (groupSettings[group.id]?.days || []).includes(i); return ( ); })}
{/* GPS MISTO */}
Místo výkonu (volitelné)
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" }} />
updateGroupSetting(group.id, "gps", e.target.value)} style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }} />
Polomer: updateGroupSetting(group.id, "radius", Number(e.target.value))} style={{ flex: 1 }} /> {groupSettings[group.id]?.radius || 200}m
{/* ULOZIT */}
)}
))}
)} {/* Save button */} {/* Logout */} {/* App info */}

{t("common.appName")} {t("common.appVersion")}

Smazat ucet

Trvale smazat ucet a vsechna data. Tuto akci nelze vratit.

); } // --- WebAuthn buffer helpers --- function base64urlToBuffer(base64url: string): ArrayBuffer { const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4)); const binary = atob(base64 + pad); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function bufferToBase64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }