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:
2026-03-30 01:54:54 +00:00
parent 6d68b68412
commit 926a584789
14 changed files with 692 additions and 62 deletions

View File

@@ -63,25 +63,21 @@ export default function CalendarPage() {
extendedProps: { status: tk.status, group: tk.group_name },
}));
// Build background events from unique groups
const groupColors = new Map<string, string>();
tasks.forEach(tk => {
if (tk.group_name && tk.group_color) {
groupColors.set(tk.group_name, tk.group_color);
}
});
if (!token) return null;
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
<div className="pb-24 sm:pb-8 px-4 sm:px-0">
{/* Compact single-row header */}
<div className="flex items-center justify-between gap-2 mb-3">
<h1 className="text-xl font-bold dark:text-white truncate">{t('calendar.title')}</h1>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
<div className="bg-white dark:bg-gray-800 rounded-xl p-2 sm:p-4 shadow calendar-compact">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek"

View File

@@ -254,20 +254,39 @@ main {
}
/* ============================
FullCalendar Mobile Layout Fix
FullCalendar Compact Layout
============================ */
/* Base toolbar styles - compact */
/* Single-row toolbar: date + nav + view switcher all in one line */
.fc .fc-toolbar {
flex-wrap: wrap;
gap: 4px 8px;
row-gap: 6px;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
font-size: 14px;
padding: 0;
margin-bottom: 8px !important;
}
/* Title: prevent vertical text wrapping */
/* Each toolbar chunk inline */
.fc .fc-toolbar-chunk {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* Center chunk (title) takes remaining space, truncates */
.fc .fc-toolbar-chunk:nth-child(2) {
flex: 1;
min-width: 0;
justify-content: center;
}
/* Title: compact, truncates */
.fc .fc-toolbar-title {
font-size: 18px !important;
font-size: 16px !important;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -275,56 +294,49 @@ main {
/* Compact buttons */
.fc .fc-button {
padding: 4px 10px !important;
font-size: 13px !important;
line-height: 1.4 !important;
padding: 4px 8px !important;
font-size: 12px !important;
line-height: 1.3 !important;
min-height: 32px;
border-radius: 6px;
}
/* Button group: no extra gaps */
/* Button group: pill shape */
.fc .fc-button-group {
gap: 0;
}
.fc .fc-button-group .fc-button {
border-radius: 0 !important;
}
.fc .fc-button-group .fc-button:first-child {
border-radius: 9999px 0 0 9999px !important;
}
.fc .fc-button-group .fc-button:last-child {
border-radius: 0 9999px 9999px 0 !important;
}
/* Mobile breakpoint: stack toolbar rows */
/* Mobile: tighten further but keep single row */
@media (max-width: 640px) {
.fc .fc-toolbar {
flex-direction: column;
align-items: center;
gap: 6px;
}
.fc .fc-toolbar-chunk {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
width: 100%;
}
/* Title on its own row, centered, smaller */
.fc .fc-toolbar-title {
font-size: 15px !important;
text-align: center;
width: 100%;
order: -1;
font-size: 13px !important;
}
/* Smaller buttons on mobile */
.fc .fc-button {
padding: 3px 8px !important;
font-size: 12px !important;
padding: 3px 6px !important;
font-size: 11px !important;
min-height: 32px !important;
}
/* View switcher as pill buttons */
.fc .fc-button-group .fc-button {
border-radius: 0 !important;
}
.fc .fc-button-group .fc-button:first-child {
border-radius: 9999px 0 0 9999px !important;
}
.fc .fc-button-group .fc-button:last-child {
border-radius: 0 9999px 9999px 0 !important;
/* Hide text labels on smallest screens - show abbreviated */
.fc .fc-dayGridMonth-button,
.fc .fc-timeGridWeek-button,
.fc .fc-timeGridDay-button {
font-size: 10px !important;
padding: 3px 5px !important;
}
/* Reduce page padding */
@@ -339,22 +351,26 @@ main {
/* Day header smaller */
.fc .fc-col-header-cell-cushion {
font-size: 12px;
padding: 4px 2px;
font-size: 11px;
padding: 3px 2px;
}
}
/* Small mobile (< 400px) - even tighter */
@media (max-width: 400px) {
.fc .fc-toolbar-title {
font-size: 13px !important;
font-size: 12px !important;
}
.fc .fc-button {
padding: 2px 6px !important;
font-size: 11px !important;
padding: 2px 4px !important;
font-size: 10px !important;
min-height: 28px !important;
}
.fc .fc-today-button {
display: none;
}
}
/* Status dot pulse animation for in_progress tasks */
@@ -366,3 +382,41 @@ main {
.status-dot-active {
animation: statusPulse 2s ease-in-out infinite;
}
/* ============================
Mobile Responsive Compact Headers (#9)
============================ */
/* Ensure no horizontal overflow on any page */
@media (max-width: 640px) {
main {
overflow-x: hidden;
}
/* Page action bars: single row, no wrap */
.flex.items-center.justify-between {
flex-wrap: nowrap;
overflow: hidden;
}
/* Touch targets minimum 44px on mobile */
button,
a[role='button'],
[role='button'] {
min-height: 44px;
min-width: 44px;
}
/* Exception: status pills and small inline elements */
.scrollbar-hide button,
.fc button {
min-width: auto;
min-height: 28px;
}
/* Compact text on mobile */
h1 {
font-size: 1.125rem;
line-height: 1.4;
}
}

View File

@@ -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}

View File

@@ -145,7 +145,7 @@ export default function TasksPage() {
<div style={{
display: "flex", alignItems: "center",
padding: "0 8px",
position: "sticky", top: 44, zIndex: 40,
position: "sticky", top: 40, zIndex: 40,
height: 40, maxHeight: 40,
background: "var(--background, #fff)",
borderBottom: "1px solid var(--border, #e5e7eb)",
@@ -216,7 +216,7 @@ export default function TasksPage() {
{/* Status pills — horizontal scroll, right side */}
<div style={{
display: "flex", alignItems: "center", gap: 4,
flex: 1, overflow: "hidden", marginLeft: 0,
flex: 1, overflow: "hidden", marginLeft: 6,
justifyContent: "flex-end",
}}
className="scrollbar-hide"

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect, useState } from 'react';
export default function WidgetPage() {
const [tasks, setTasks] = useState<any[]>([]);
useEffect(() => {
const token = localStorage.getItem('taskteam_token');
if (token) {
fetch('/api/v1/tasks?limit=5&status=pending', { headers: { Authorization: 'Bearer ' + token } })
.then(r => r.json()).then(d => setTasks(d.data || []));
}
}, []);
return (
<div style={{ padding: 8, background: '#0F172A', minHeight: '100vh', color: 'white', fontSize: 14 }}>
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>Task Team</div>
{tasks.map(t => (
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #1E293B', display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: t.status === 'in_progress' ? '#F59E0B' : '#6B7280', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
</div>
))}
{!tasks.length && <div style={{ opacity: 0.5 }}>Zadne ukoly</div>}
</div>
);
}

View File

@@ -135,6 +135,19 @@ export interface Task {
group_icon: string | null;
}
export interface GroupTimeZone {
days: number[];
from: string;
to: string;
}
export interface GroupLocation {
name: string;
lat: number | null;
lng: number | null;
radius_m: number;
}
export interface Group {
id: string;
name: string;
@@ -142,6 +155,8 @@ export interface Group {
icon: string | null;
sort_order: number;
display_name?: string;
time_zones: GroupTimeZone[];
locations: GroupLocation[];
}
export interface Connector {

View File

@@ -12,6 +12,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
@@ -559,6 +560,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-12.0.0.tgz",
"integrity": "sha512-0w6W8qkACycyaRMb2XnHfpA9kkgs5e2Aw2Ul9ObBYmvFBbtzipyWu9u2+WP1wy98chM+GIlQFnPheUbiMBQr8w==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/types": "^12.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",

View File

@@ -13,6 +13,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@simplewebauthn/browser": "^12.0.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",

View File

@@ -1 +1,46 @@
{"name":"Task Team","short_name":"Tasks","start_url":"/","display":"standalone","background_color":"#0A0A0F","theme_color":"#1D4ED8","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
{
"name": "Task Team",
"short_name": "Tasks",
"start_url": "/",
"display": "standalone",
"background_color": "#0A0A0F",
"theme_color": "#1D4ED8",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Novy ukol",
"short_name": "Pridat",
"url": "/tasks?action=new",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Widget",
"short_name": "Widget",
"url": "/widget",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192"
}
]
}
]
}