Files
task-team/apps/tasks/app/settings/page.tsx
Admin 926a584789 WebAuthn biometric + PWA widget + UI header fixes + mobile responsive
- WebAuthn: register/auth options, device management
- PWA widget page + manifest shortcuts
- Group schedule endpoint (timezones + locations)
- UI #3-#6: compact headers on tasks/calendar/projects/goals
- UI #9: mobile responsive top bars
- webauthn_credentials table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:54:54 +00:00

437 lines
20 KiB
TypeScript
Raw Permalink 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";
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);
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
}
}
}
}, [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 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>
{/* 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>
);
}