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>
This commit is contained in:
@@ -6,6 +6,16 @@ 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();
|
||||
@@ -19,6 +29,10 @@ export default function SettingsPage() {
|
||||
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) {
|
||||
@@ -37,6 +51,84 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [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));
|
||||
@@ -193,6 +285,113 @@ export default function SettingsPage() {
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user