Compare commits

..

11 Commits

Author SHA1 Message Date
Claude e6b81bef3c wip: session paused — all tasks complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:40:52 +02:00
Claude 001bfb3a0c Clip search widget content overflow and set sane minimum height
Prevents form fields from overlapping the widget border when sized
small. Changed overflow from visible to hidden and set minH back to 5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:31:28 +02:00
Claude 30316e2353 Fix search widget: remove stray border line, reduce height
- Remove header border-bottom for search widget in all states
- Fix 10 dead CSS references to widgetCardEditMode → widgetEditing
- Reduce minimum grid height from 5 to 3 rows
- Reduce default creation height from 12 to 7 rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:27:47 +02:00
Claude 80bd18a630 Load calendar data async so slow/failing sources don't block page load
Split loadDashboardData into two phases: core data (settings, widgets,
notes) renders immediately, then calendar events load in the background.
Uses Promise.allSettled so individual calendar source failures don't
affect other sources or the dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:19:44 +02:00
Claude 09a0e44b30 Use accent color for stopwatch buttons instead of hardcoded green/red
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:07:21 +02:00
Claude b82e0e68c5 Add stopwatch to API widget type validation and defaults
The API has its own WidgetType and normalizeWidgetType that were not
updated when the stopwatch widget was added, causing 'Widget-Typ ist
ungültig' on creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:03:35 +02:00
Claude 04d14b3aea Add stopwatch widget with lap functionality
- New StopwatchWidget component with start/stop/reset and lap tracking
- Display shows MM:SS.cc with tabular-nums for stable layout
- Lap table shows split time and cumulative total per lap
- Uses requestAnimationFrame for smooth centisecond updates
- Registered as 'stopwatch' widget type, min grid size 6x8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 11:58:40 +02:00
Claude ca76bf315c Move drag handle to top center edge of widget card
- Move drag handle out of widgetHeader into widgetCard directly so it
  sits at the top edge of the card, centered horizontally
- Style as a small tab (36x14px bar) protruding from the top border
- Always visible in edit mode for all widget types
- Remove old inline-flex overrides that conflicted with the new layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 11:54:19 +02:00
Claude 88614b080a Center drag handle at top of all widgets and fix dead edit-mode CSS
- Position drag handle at left: 50% with translateX(-50%) instead of
  left: 6px so it appears centered at the top of every widget
- Fix widget-density.css referencing non-existent class widgetCardEditMode
  (the actual class is widgetEditing), which caused edit-mode header
  padding adjustments to never apply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 11:45:42 +02:00
Claude d277789aa6 Add admin password reset for users
- Extend PATCH /api/users/[id] to accept optional password field
- Hash with bcrypt (cost 12), invalidate target user's sessions
- Add "Passwort" button and modal dialog in admin user management
- Validate password length (10-128 chars) via existing normalizePassword

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 11:22:11 +02:00
Claude a63c1069dd Fix Docker port mapping: container listens on 3130, not 3000
The PORT env var overrides the internal port to 3130, so the mapping
must forward to 3130 instead of the Dockerfile default 3000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 11:17:22 +02:00
15 changed files with 660 additions and 91 deletions
+72
View File
@@ -0,0 +1,72 @@
---
context: default
phase: freeform-session
task: null
total_tasks: null
status: complete
last_updated: 2026-06-20T12:30:00Z
---
<current_state>
All requested work is complete and deployed. No pending tasks.
The dashboard is running in Docker on port 3130, accessible via Nginx Proxy Manager.
All commits pushed to Gitea (git.heidencrime.de/schalli/personal-dashboard).
</current_state>
<completed_work>
Session commits (10 total):
- a63c106: Fix Docker port mapping — container listens on 3130, not 3000
- d277789: Add admin password reset for users (PATCH /api/users/[id] with password field, modal UI in admin/users)
- 88614b0: Center drag handle at top of all widgets, fix dead widgetCardEditMode CSS
- ca76bf3: Move drag handle to top center edge of widget card (out of header, tab-style at top border)
- 04d14b3: Add stopwatch widget with lap functionality (new component, CSS, type registration)
- b82e0e6: Fix stopwatch API type validation (API has its own WidgetType that needs separate updates)
- 09a0e44: Use accent color for stopwatch buttons instead of hardcoded green/red
- 80bd18a: Load calendar data async so slow/failing sources don't block page load
- 30316e2: Fix search widget border line, reduce height, fix 10 dead widgetCardEditMode refs
- 001bfb3: Clip search widget content overflow on small widget sizes
</completed_work>
<remaining_work>
No remaining work from this session.
</remaining_work>
<decisions_made>
- Calendar data loads async after core dashboard data — slow Exchange EWS sources were blocking entire page load for 15+ seconds
- Drag handle moved from inside widgetHeader to direct child of widgetCard — enables positioning at top center edge independent of header
- API route (src/app/api/widgets/route.ts) has its OWN WidgetType definition separate from src/types/dashboard.ts — both must be updated when adding new widget types
- widget-density.css and search-widget.css referenced non-existent class widgetCardEditMode — the actual class in JSX is widgetEditing. All 10+ references fixed
- .netrc was in wrong format (Username:/Password: instead of machine/login/password) — fixed for Gitea token auth
</decisions_made>
<blockers>
None.
</blockers>
## Required Reading (in order)
1. `src/app/page.tsx` — main dashboard, renderWidget() structure, loadDashboardData/loadCalendarData split
2. `src/app/api/widgets/route.ts` — widget creation API with its own WidgetType and normalizeWidgetType
3. `src/app/widget-density.css` — compact density overrides, uses widgetEditing class
4. `src/app/search-widget.css` — complex CSS with many override layers
## Infrastructure State
- Docker container `personal-dashboard` running on port 3130 (host) → 3130 (container)
- Nginx Proxy Manager (`npmplus`) in host network mode — forward to 127.0.0.1:3130
- Gitea remote: https://git.heidencrime.de/schalli/personal-dashboard.git
- Auth via ~/.netrc (token-based)
- SQLite database at /data/dashboard.db (Docker volume `personal-dashboard-data`)
- 3 users: admin@heidencrime.de, schalli@heidencrime.de, kschaller@ctl.de
- Calendar sources: 2 Exchange EWS (owa.ctl.de), 1 ICS (SOGo)
<context>
Freeform user-driven session. User (schalli) is a German-speaking dev who self-hosts everything.
All communication in German. Work was entirely reactive to user requests — no roadmap or phases.
CSS has significant technical debt from many layered fixes with !important overrides.
The search-widget.css in particular has ~380 lines of overlapping rules that could benefit from a rewrite.
</context>
<next_action>
Start with: No pending work. Ask user what they'd like to work on next.
</next_action>
+36
View File
@@ -0,0 +1,36 @@
{
"version": "1.0",
"timestamp": "2026-06-20T12:30:00Z",
"phase": null,
"phase_name": "freeform-session",
"phase_dir": null,
"plan": null,
"task": null,
"total_tasks": null,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "Fix Docker port mapping (3130:3130)", "status": "done", "commit": "a63c106"},
{"id": 2, "name": "Add admin password reset feature", "status": "done", "commit": "d277789"},
{"id": 3, "name": "Center drag handle at top of all widgets", "status": "done", "commit": "88614b0"},
{"id": 4, "name": "Move drag handle to top center edge of widget card", "status": "done", "commit": "ca76bf3"},
{"id": 5, "name": "Add stopwatch widget with lap functionality", "status": "done", "commit": "04d14b3"},
{"id": 6, "name": "Fix stopwatch API type validation", "status": "done", "commit": "b82e0e6"},
{"id": 7, "name": "Use accent color for stopwatch buttons", "status": "done", "commit": "09a0e44"},
{"id": 8, "name": "Load calendar data async (fix page hang)", "status": "done", "commit": "80bd18a"},
{"id": 9, "name": "Fix search widget border line and reduce height", "status": "done", "commit": "30316e2"},
{"id": 10, "name": "Clip search widget overflow on small sizes", "status": "done", "commit": "001bfb3"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [],
"decisions": [
{"decision": "Calendar data loads async after core dashboard data", "rationale": "Slow/unreachable calendar sources (Exchange EWS) were blocking entire page load", "phase": null},
{"decision": "Drag handle moved out of widgetHeader into widgetCard", "rationale": "Allows positioning at top center edge of card, independent of header layout", "phase": null},
{"decision": "API has its own WidgetType — must be updated separately from types/dashboard.ts", "rationale": "Discovered when stopwatch creation failed with 'Widget-Typ ist ungültig'", "phase": null},
{"decision": "widget-density.css used dead class widgetCardEditMode — replaced with widgetEditing", "rationale": "JSX uses widgetEditing, not widgetCardEditMode. Caused edit-mode CSS to never apply", "phase": null}
],
"uncommitted_files": [],
"next_action": "All requested work is complete. No pending tasks.",
"context_notes": "Freeform session: user-driven feature requests and bug fixes on personal-dashboard. All changes deployed to Docker container on port 3130, pushed to Gitea at git.heidencrime.de. .netrc fixed for token auth. NPM proxy should point to 127.0.0.1:3130."
}
+1 -1
View File
@@ -23,7 +23,7 @@ services:
ALLOW_INSECURE_CALENDAR_HTTP: "${ALLOW_INSECURE_CALENDAR_HTTP:-false}" ALLOW_INSECURE_CALENDAR_HTTP: "${ALLOW_INSECURE_CALENDAR_HTTP:-false}"
ALLOW_INSECURE_EXCHANGE_HTTP: "${ALLOW_INSECURE_EXCHANGE_HTTP:-false}" ALLOW_INSECURE_EXCHANGE_HTTP: "${ALLOW_INSECURE_EXCHANGE_HTTP:-false}"
ports: ports:
- "3130:3000" - "3130:3130"
volumes: volumes:
- personal-dashboard-data:/data - personal-dashboard-data:/data
+89
View File
@@ -59,6 +59,9 @@ export default function AdminUsersPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingUserId, setSavingUserId] = useState<string | null>(null); const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null); const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [resetPasswordUserId, setResetPasswordUserId] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState("");
const [resettingPassword, setResettingPassword] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -235,6 +238,43 @@ export default function AdminUsersPage() {
} }
} }
function openResetPassword(userId: string) {
setResetPasswordUserId(userId);
setNewPassword("");
setError(null);
setMessage(null);
}
async function resetPassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!resetPasswordUserId) {
return;
}
setResettingPassword(true);
setError(null);
setMessage(null);
try {
const response = await fetch(`/api/users/${encodeURIComponent(resetPasswordUserId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword })
});
await parseJsonResponse<{ ok: boolean }>(response);
setResetPasswordUserId(null);
setNewPassword("");
setMessage("Passwort wurde zurückgesetzt.");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Passwort konnte nicht zurückgesetzt werden.");
} finally {
setResettingPassword(false);
}
}
const adminCount = users.filter((user) => user.role === "ADMIN").length; const adminCount = users.filter((user) => user.role === "ADMIN").length;
return ( return (
@@ -362,6 +402,14 @@ export default function AdminUsersPage() {
Bearbeiten Bearbeiten
</button> </button>
<button
type="button"
className="button buttonSecondary adminTableButton"
onClick={() => openResetPassword(user.id)}
>
Passwort
</button>
<button <button
type="button" type="button"
className="button buttonDanger adminTableButton" className="button buttonDanger adminTableButton"
@@ -389,6 +437,47 @@ export default function AdminUsersPage() {
) : null} ) : null}
</div> </div>
</section> </section>
{resetPasswordUserId ? (
<div className="modalOverlay" onClick={() => setResetPasswordUserId(null)}>
<div className="modalContent" onClick={(e) => e.stopPropagation()}>
<h2>Passwort zurücksetzen</h2>
<p className="muted">
Neues Passwort für{" "}
<strong>{users.find((u) => u.id === resetPasswordUserId)?.email}</strong>
</p>
<form onSubmit={(e) => void resetPassword(e)}>
<input
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Neues Passwort (min. 10 Zeichen)"
minLength={10}
maxLength={128}
required
autoFocus
/>
<div className="modalActions">
<button type="submit" className="button" disabled={resettingPassword}>
{resettingPassword ? "Speichere..." : "Passwort setzen"}
</button>
<button
type="button"
className="button buttonSecondary"
onClick={() => setResetPasswordUserId(null)}
disabled={resettingPassword}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
) : null}
</main> </main>
); );
} }
+37 -7
View File
@@ -1,8 +1,10 @@
import { rm } from "fs/promises"; import { rm } from "fs/promises";
import path from "path"; import path from "path";
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth"; import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { normalizePassword } from "@/lib/validation";
type RouteContext = { type RouteContext = {
params: Promise<{ params: Promise<{
@@ -79,19 +81,13 @@ export async function PATCH(request: Request, context: RouteContext) {
const body = (await request.json().catch(() => null)) as { const body = (await request.json().catch(() => null)) as {
email?: unknown; email?: unknown;
displayName?: unknown; displayName?: unknown;
password?: unknown;
} | null; } | null;
if (!body) { if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
} }
const email = normalizeEmail(body.email);
const displayName = normalizeDisplayName(body.displayName);
if (!email) {
return NextResponse.json({ error: "E-Mail ist ungültig." }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
where: { where: {
id: userId id: userId
@@ -102,6 +98,40 @@ export async function PATCH(request: Request, context: RouteContext) {
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
} }
if (body.password !== undefined) {
const password = normalizePassword(body.password);
if (!password) {
return NextResponse.json(
{ error: "Passwort muss zwischen 10 und 128 Zeichen lang sein." },
{ status: 400 }
);
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.update({
where: { id: userId },
data: { passwordHash }
});
await prisma.session.deleteMany({
where: {
userId,
...(userId === currentUser.id ? {} : undefined)
}
});
return NextResponse.json({ ok: true, message: "Passwort wurde zurückgesetzt." });
}
const email = normalizeEmail(body.email);
const displayName = normalizeDisplayName(body.displayName);
if (!email) {
return NextResponse.json({ error: "E-Mail ist ungültig." }, { status: 400 });
}
const emailOwner = await prisma.user.findUnique({ const emailOwner = await prisma.user.findUnique({
where: { where: {
email email
+9 -3
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth"; import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check"; type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check" | "stopwatch";
type ExistingWidgetBox = { type ExistingWidgetBox = {
x: number; x: number;
@@ -33,7 +33,7 @@ const widgetCatalog: Record<WidgetType, WidgetDefaults> = {
search: { search: {
title: "Suche", title: "Suche",
w: 16, w: 16,
h: 12 h: 7
}, },
calendar: { calendar: {
title: "Kalender", title: "Kalender",
@@ -54,6 +54,11 @@ const widgetCatalog: Record<WidgetType, WidgetDefaults> = {
title: "Domainprüfung", title: "Domainprüfung",
w: 16, w: 16,
h: 12 h: 12
},
stopwatch: {
title: "Stoppuhr",
w: 12,
h: 20
} }
}; };
@@ -65,7 +70,8 @@ function normalizeWidgetType(value: unknown): WidgetType | null {
value === "calendar" || value === "calendar" ||
value === "calculator" || value === "calculator" ||
value === "clock" || value === "clock" ||
value === "domain-check" value === "domain-check" ||
value === "stopwatch"
) { ) {
return value; return value;
} }
+58 -19
View File
@@ -901,6 +901,42 @@ button:disabled {
margin: 0; margin: 0;
} }
.modalOverlay {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.5);
}
.modalContent {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 420px;
display: grid;
gap: 14px;
}
.modalContent h2 {
margin: 0;
font-size: 20px;
}
.modalContent .input {
width: 100%;
}
.modalActions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 4px;
}
.logoUploadLayout { .logoUploadLayout {
display: grid; display: grid;
grid-template-columns: 160px minmax(0, 1fr); grid-template-columns: 160px minmax(0, 1fr);
@@ -2683,7 +2719,12 @@ button:disabled {
z-index: 20 !important; z-index: 20 !important;
} }
.widgetCard.widgetEditing .widgetDragHandle, .widgetCard.widgetEditing .widgetDragHandle {
display: grid !important;
visibility: visible !important;
opacity: 1 !important;
}
.widgetCard.widgetEditing .widgetMenu { .widgetCard.widgetEditing .widgetMenu {
display: inline-flex !important; display: inline-flex !important;
visibility: visible !important; visibility: visible !important;
@@ -2769,7 +2810,6 @@ button:disabled {
align-self: center !important; align-self: center !important;
} }
.widgetCard.widgetEditing .widgetDragHandle,
.widgetCard.widgetEditing .widgetMenuButton { .widgetCard.widgetEditing .widgetMenuButton {
height: 32px !important; height: 32px !important;
min-height: 32px !important; min-height: 32px !important;
@@ -2822,7 +2862,6 @@ button:disabled {
.widgetCard.widgetEditing .widgetTitle, .widgetCard.widgetEditing .widgetTitle,
.widgetCard.widgetEditing .widgetTitle h2, .widgetCard.widgetEditing .widgetTitle h2,
.widgetCard.widgetEditing .widgetTitleInput, .widgetCard.widgetEditing .widgetTitleInput,
.widgetCard.widgetEditing .widgetDragHandle,
.widgetCard.widgetEditing .widgetMenu, .widgetCard.widgetEditing .widgetMenu,
.widgetCard.widgetEditing .widgetMenuButton { .widgetCard.widgetEditing .widgetMenuButton {
height: 30px !important; height: 30px !important;
@@ -2830,7 +2869,6 @@ button:disabled {
align-self: center !important; align-self: center !important;
} }
.widgetCard.widgetEditing .widgetDragHandle,
.widgetCard.widgetEditing .widgetMenuButton { .widgetCard.widgetEditing .widgetMenuButton {
padding: 0 9px !important; padding: 0 9px !important;
display: inline-flex !important; display: inline-flex !important;
@@ -3076,28 +3114,30 @@ button:disabled {
line-height: 24px !important; line-height: 24px !important;
} }
/* Griff statt großer Ziehen-Button */ /* Drag-Handle: zentriert am oberen Rand des Widget-Cards */
.widgetDragHandle { .widgetDragHandle {
position: absolute !important; position: absolute !important;
top: 5px !important; top: -6px !important;
left: 7px !important; left: 50% !important;
transform: translateX(-50%) !important;
z-index: 80 !important; z-index: 80 !important;
width: 20px !important; width: 36px !important;
height: 22px !important; height: 14px !important;
display: grid !important; display: grid !important;
place-items: center !important; place-items: center !important;
padding: 0 !important; padding: 0 !important;
color: var(--muted) !important; color: var(--muted) !important;
background: transparent !important; background: var(--surface-strong) !important;
border: 0 !important; border: 1px solid var(--border) !important;
border-radius: 8px !important; border-radius: 0 0 7px 7px !important;
cursor: grab !important; cursor: grab !important;
user-select: none !important; user-select: none !important;
touch-action: none !important; touch-action: none !important;
} }
.widgetDragHandle:hover { .widgetDragHandle:hover {
background: color-mix(in srgb, var(--accent-soft) 38%, transparent) !important; background: color-mix(in srgb, var(--accent-soft) 50%, var(--surface-strong)) !important;
border-color: var(--accent) !important;
color: var(--accent) !important; color: var(--accent) !important;
} }
@@ -3106,13 +3146,12 @@ button:disabled {
} }
.widgetDragGrip { .widgetDragGrip {
width: 13px !important; width: 18px !important;
height: 17px !important; height: 4px !important;
display: block !important; display: block !important;
opacity: 0.72 !important; opacity: 0.6 !important;
background-image: radial-gradient(currentColor 1.3px, transparent 1.4px) !important; background: currentColor !important;
background-size: 6px 6px !important; border-radius: 2px !important;
background-position: 0 0 !important;
} }
/* Menü aus dem Layoutfluss nehmen */ /* Menü aus dem Layoutfluss nehmen */
+1
View File
@@ -11,6 +11,7 @@ import "./calendar-scale.css";
import "./widget-density.css"; import "./widget-density.css";
import "./clock-widget.css"; import "./clock-widget.css";
import "./search-widget.css"; import "./search-widget.css";
import "./stopwatch-widget.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Personal Dashboard", title: "Personal Dashboard",
+62 -37
View File
@@ -13,6 +13,7 @@ import ClockWidget from "@/components/ClockWidget";
import DomainCheckWidget from "@/components/DomainCheckWidget"; import DomainCheckWidget from "@/components/DomainCheckWidget";
import FavoritesWidget from "@/components/FavoritesWidget"; import FavoritesWidget from "@/components/FavoritesWidget";
import NoteWidget from "@/components/NoteWidget"; import NoteWidget from "@/components/NoteWidget";
import StopwatchWidget from "@/components/StopwatchWidget";
const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), { const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), {
ssr: false, ssr: false,
@@ -80,6 +81,10 @@ const widgetCatalog: Array<{
{ {
type: "domain-check", type: "domain-check",
title: "Domainprüfung" title: "Domainprüfung"
},
{
type: "stopwatch",
title: "Stoppuhr"
} }
]; ];
@@ -376,33 +381,21 @@ export default function DashboardPage() {
} }
} }
async function loadDashboardData() { async function loadCalendarData(widgets: Widget[]) {
setDashboardError(null);
try { try {
const [settingsResponse, widgetsResponse, notesResponse] = await Promise.all([
fetch("/api/settings", {
cache: "no-store"
}),
fetch("/api/widgets", {
cache: "no-store"
}),
fetch("/api/notes", {
cache: "no-store"
})
]);
const settingsData = await parseJsonResponse<{ settings: Settings }>(settingsResponse);
const widgetsData = await parseJsonResponse<{ widgets: Widget[] }>(widgetsResponse);
const notesData = await parseJsonResponse<{ notes: NoteBoardItem[] }>(notesResponse);
const allSourcesResponse = await fetch("/api/calendar/source", { const allSourcesResponse = await fetch("/api/calendar/source", {
cache: "no-store" cache: "no-store"
}); });
const allSourcesData = await parseJsonResponse<{ sources: CalendarSource[] }>(allSourcesResponse); const allSourcesData = await parseJsonResponse<{ sources: CalendarSource[] }>(allSourcesResponse);
setCalendarSources(allSourcesData.sources);
const calendarWidgets = widgetsData.widgets.filter((widget) => widget.type === "calendar"); const calendarWidgets = widgets.filter((widget) => widget.type === "calendar");
const calendarLoads = await Promise.all(
if (calendarWidgets.length === 0) {
return;
}
const calendarLoads = await Promise.allSettled(
calendarWidgets.map(async (widget) => { calendarWidgets.map(async (widget) => {
const [configResponse, eventsResponse] = await Promise.all([ const [configResponse, eventsResponse] = await Promise.all([
fetch(`/api/calendar/source?widgetId=${encodeURIComponent(widget.id)}`, { fetch(`/api/calendar/source?widgetId=${encodeURIComponent(widget.id)}`, {
@@ -434,21 +427,49 @@ export default function DashboardPage() {
const nextSelectionsByWidget: Record<string, string[]> = {}; const nextSelectionsByWidget: Record<string, string[]> = {};
const nextEventsCountByWidget: Record<string, number> = {}; const nextEventsCountByWidget: Record<string, number> = {};
calendarLoads.forEach((calendarLoad) => { calendarLoads.forEach((result) => {
nextEventsByWidget[calendarLoad.widgetId] = calendarLoad.events; if (result.status === "fulfilled") {
nextErrorsByWidget[calendarLoad.widgetId] = calendarLoad.error; nextEventsByWidget[result.value.widgetId] = result.value.events;
nextSelectionsByWidget[calendarLoad.widgetId] = calendarLoad.selectedSourceIds; nextErrorsByWidget[result.value.widgetId] = result.value.error;
nextEventsCountByWidget[calendarLoad.widgetId] = calendarLoad.nextEventsCount; nextSelectionsByWidget[result.value.widgetId] = result.value.selectedSourceIds;
nextEventsCountByWidget[result.value.widgetId] = result.value.nextEventsCount;
}
}); });
setCalendarEventsByWidget(nextEventsByWidget);
setCalendarErrorsByWidget(nextErrorsByWidget);
setCalendarSelectionsByWidget(nextSelectionsByWidget);
setCalendarNextEventsCountByWidget(nextEventsCountByWidget);
} catch {
// calendar load failure must not block dashboard
}
}
async function loadDashboardData() {
setDashboardError(null);
try {
const [settingsResponse, widgetsResponse, notesResponse] = await Promise.all([
fetch("/api/settings", {
cache: "no-store"
}),
fetch("/api/widgets", {
cache: "no-store"
}),
fetch("/api/notes", {
cache: "no-store"
})
]);
const settingsData = await parseJsonResponse<{ settings: Settings }>(settingsResponse);
const widgetsData = await parseJsonResponse<{ widgets: Widget[] }>(widgetsResponse);
const notesData = await parseJsonResponse<{ notes: NoteBoardItem[] }>(notesResponse);
setSettings(settingsData.settings); setSettings(settingsData.settings);
setWidgets(widgetsData.widgets); setWidgets(widgetsData.widgets);
setNotes(notesData.notes); setNotes(notesData.notes);
setCalendarEventsByWidget(nextEventsByWidget);
setCalendarErrorsByWidget(nextErrorsByWidget); void loadCalendarData(widgetsData.widgets);
setCalendarSources(allSourcesData.sources);
setCalendarSelectionsByWidget(nextSelectionsByWidget);
setCalendarNextEventsCountByWidget(nextEventsCountByWidget);
} catch (error) { } catch (error) {
setDashboardError(error instanceof Error ? error.message : "Dashboard konnte nicht geladen werden."); setDashboardError(error instanceof Error ? error.message : "Dashboard konnte nicht geladen werden.");
} }
@@ -1194,6 +1215,10 @@ export default function DashboardPage() {
return <DomainCheckWidget />; return <DomainCheckWidget />;
} }
if (widget.type === "stopwatch") {
return <StopwatchWidget />;
}
return <p className="muted">Dieses Widget wird noch nicht unterstützt.</p>; return <p className="muted">Dieses Widget wird noch nicht unterstützt.</p>;
} }
@@ -1350,13 +1375,13 @@ export default function DashboardPage() {
} as CSSProperties } as CSSProperties
} }
> >
<div className="widgetHeader"> {editMode ? (
{editMode ? ( <span className="widgetDragHandle" title="Widget ziehen" aria-label="Widget ziehen">
<span className="widgetDragHandle" title="Widget ziehen" aria-label="Widget ziehen"> <span className="widgetDragGrip" aria-hidden="true" />
<span className="widgetDragGrip" aria-hidden="true" /> </span>
</span> ) : null}
) : null}
<div className="widgetHeader">
{typedWidget.type === "clock" || typedWidget.type === "search" ? null : ( {typedWidget.type === "clock" || typedWidget.type === "search" ? null : (
<div className="widgetTitle">{renderWidgetTitle(typedWidget)}</div> <div className="widgetTitle">{renderWidgetTitle(typedWidget)}</div>
)} )}
+16 -12
View File
@@ -20,8 +20,12 @@
display: none !important; display: none !important;
} }
.app .widgetCard-search .widgetHeader {
border-bottom: 0 !important;
}
/* Normalmodus: kein leerer Titelbereich */ /* Normalmodus: kein leerer Titelbereich */
.app .widgetCard-search:not(.widgetCardEditMode) .widgetHeader { .app .widgetCard-search:not(.widgetEditing) .widgetHeader {
height: 0 !important; height: 0 !important;
min-height: 0 !important; min-height: 0 !important;
max-height: 0 !important; max-height: 0 !important;
@@ -31,7 +35,7 @@
} }
/* Bearbeitungsmodus: Header bleibt fuer Griff und Menue vorhanden */ /* Bearbeitungsmodus: Header bleibt fuer Griff und Menue vorhanden */
.app .widgetCard-search.widgetCardEditMode .widgetHeader { .app .widgetCard-search.widgetEditing .widgetHeader {
height: 26px !important; height: 26px !important;
min-height: 26px !important; min-height: 26px !important;
max-height: 26px !important; max-height: 26px !important;
@@ -68,7 +72,7 @@
} }
/* Normalmodus: Suchzeile in kompletter Widgetflaeche zentrieren */ /* Normalmodus: Suchzeile in kompletter Widgetflaeche zentrieren */
.app .widgetCard-search:not(.widgetCardEditMode) .widgetContent { .app .widgetCard-search:not(.widgetEditing) .widgetContent {
position: absolute !important; position: absolute !important;
inset: 0 !important; inset: 0 !important;
width: auto !important; width: auto !important;
@@ -78,11 +82,11 @@
display: grid !important; display: grid !important;
place-items: center !important; place-items: center !important;
padding: 8px !important; padding: 8px !important;
overflow: visible !important; overflow: hidden !important;
} }
/* Bearbeitungsmodus: unterhalb Header zentrieren, Resize-Ecke freihalten */ /* Bearbeitungsmodus: unterhalb Header zentrieren, Resize-Ecke freihalten */
.app .widgetCard-search.widgetCardEditMode .widgetContent { .app .widgetCard-search.widgetEditing .widgetContent {
position: absolute !important; position: absolute !important;
inset: 26px 0 0 0 !important; inset: 26px 0 0 0 !important;
width: auto !important; width: auto !important;
@@ -92,7 +96,7 @@
display: grid !important; display: grid !important;
place-items: center !important; place-items: center !important;
padding: 6px 10px 12px 8px !important; padding: 6px 10px 12px 8px !important;
overflow: visible !important; overflow: hidden !important;
} }
/* Suchformular mittig, einzeilig, mit Luft zur Resize-Ecke */ /* Suchformular mittig, einzeilig, mit Luft zur Resize-Ecke */
@@ -192,7 +196,7 @@
} }
/* Im Bearbeitungsmodus muss der Header als Menü-/Griff-Zone sichtbar bleiben */ /* Im Bearbeitungsmodus muss der Header als Menü-/Griff-Zone sichtbar bleiben */
.app .widgetCard-search.widgetCardEditMode .widgetHeader { .app .widgetCard-search.widgetEditing .widgetHeader {
position: relative !important; position: relative !important;
z-index: 50 !important; z-index: 50 !important;
height: 26px !important; height: 26px !important;
@@ -205,7 +209,7 @@
} }
/* Menü explizit sichtbar und über dem Suchformular */ /* Menü explizit sichtbar und über dem Suchformular */
.app .widgetCard-search.widgetCardEditMode .widgetMenu { .app .widgetCard-search.widgetEditing .widgetMenu {
display: block !important; display: block !important;
position: absolute !important; position: absolute !important;
top: 2px !important; top: 2px !important;
@@ -216,7 +220,7 @@
pointer-events: auto !important; pointer-events: auto !important;
} }
.app .widgetCard-search.widgetCardEditMode .widgetMenuButton { .app .widgetCard-search.widgetEditing .widgetMenuButton {
display: inline-flex !important; display: inline-flex !important;
visibility: visible !important; visibility: visible !important;
opacity: 1 !important; opacity: 1 !important;
@@ -225,7 +229,7 @@
/* Dropdown über allem und scrollbar */ /* Dropdown über allem und scrollbar */
.app .widgetCard-search.widgetCardMenuOpen, .app .widgetCard-search.widgetCardMenuOpen,
.app .widgetCard-search.widgetCardEditMode.widgetCardMenuOpen { .app .widgetCard-search.widgetEditing.widgetCardMenuOpen {
overflow: visible !important; overflow: visible !important;
z-index: 2000 !important; z-index: 2000 !important;
} }
@@ -241,13 +245,13 @@
} }
/* Content darf das Menü nicht überdecken */ /* Content darf das Menü nicht überdecken */
.app .widgetCard-search.widgetCardEditMode .widgetContent { .app .widgetCard-search.widgetEditing .widgetContent {
z-index: 1 !important; z-index: 1 !important;
pointer-events: auto !important; pointer-events: auto !important;
} }
/* FINAL FIX: Such-Widget behält einheitliche Widget-Chrome. /* FINAL FIX: Such-Widget behält einheitliche Widget-Chrome.
Ursache: vorherige Regeln haben den Header abhängig von widgetCardEditMode versteckt. Ursache: vorherige Regeln haben den Header abhängig von widgetEditing versteckt.
Diese Regeln nutzen den tatsächlich vorhandenen Menü-/Drag-Button als Edit-Indikator. */ Diese Regeln nutzen den tatsächlich vorhandenen Menü-/Drag-Button als Edit-Indikator. */
/* Sobald Menü oder Griff existieren, ist das Widget im Bearbeitungszustand: /* Sobald Menü oder Griff existieren, ist das Widget im Bearbeitungszustand:
+131
View File
@@ -0,0 +1,131 @@
/* Stoppuhr-Widget */
.stopwatchWidget {
height: 100%;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
padding: 4px 0;
}
.stopwatchDisplay {
text-align: center;
font-size: clamp(28px, 8cqw, 52px);
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--text);
padding: 4px 0;
}
.stopwatchControls {
display: flex;
justify-content: center;
gap: 8px;
}
.stopwatchButton {
min-height: 32px;
min-width: 64px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 9px;
cursor: pointer;
font-weight: 700;
font-size: 13px;
}
.stopwatchStart {
color: var(--accent-text);
background: var(--accent);
border-color: var(--accent);
}
.stopwatchStart:hover {
filter: brightness(0.9);
}
.stopwatchStop {
color: var(--accent-text);
background: var(--accent);
border-color: var(--accent);
opacity: 0.8;
}
.stopwatchStop:hover {
opacity: 1;
}
.stopwatchLap {
color: var(--text);
background: var(--surface-strong);
}
.stopwatchLap:hover {
border-color: var(--accent);
}
.stopwatchReset {
color: var(--text);
background: var(--surface-strong);
}
.stopwatchReset:hover {
border-color: var(--accent);
}
.stopwatchLaps {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.stopwatchLapTable {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.stopwatchLapTable thead {
position: sticky;
top: 0;
z-index: 1;
}
.stopwatchLapTable th {
padding: 4px 8px;
color: var(--muted);
background: var(--surface);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.03em;
text-align: right;
border-bottom: 1px solid var(--border);
}
.stopwatchLapTable th:first-child {
text-align: left;
}
.stopwatchLapTable td {
padding: 4px 8px;
font-size: 13px;
text-align: right;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.stopwatchLapNumber {
text-align: left !important;
color: var(--muted);
font-weight: 700;
}
.stopwatchLapTime {
font-weight: 700;
}
.stopwatchLapTotal {
color: var(--muted);
}
+9 -11
View File
@@ -20,8 +20,7 @@
box-sizing: border-box !important; box-sizing: border-box !important;
} }
.app .widgetCard.widgetCardEditMode .widgetHeader { .app .widgetCard.widgetEditing .widgetHeader {
padding-left: 31px !important;
padding-right: 32px !important; padding-right: 32px !important;
} }
@@ -66,7 +65,7 @@
padding: 0 !important; padding: 0 !important;
} }
.app .widgetCard-clock.widgetCardEditMode .widgetHeader { .app .widgetCard-clock.widgetEditing .widgetHeader {
height: 24px !important; height: 24px !important;
min-height: 24px !important; min-height: 24px !important;
} }
@@ -77,10 +76,9 @@
/* Griff und Menü kleiner */ /* Griff und Menü kleiner */
.app .widgetDragHandle { .app .widgetDragHandle {
top: 2px !important; top: -6px !important;
left: 6px !important; left: 50% !important;
width: 20px !important; transform: translateX(-50%) !important;
height: 20px !important;
} }
.app .widgetMenu { .app .widgetMenu {
@@ -377,7 +375,7 @@
overflow: visible !important; overflow: visible !important;
} }
.app .widgetCard-clock.widgetCardEditMode .widgetHeader { .app .widgetCard-clock.widgetEditing .widgetHeader {
height: 24px !important; height: 24px !important;
min-height: 24px !important; min-height: 24px !important;
max-height: 24px !important; max-height: 24px !important;
@@ -393,7 +391,7 @@
overflow: visible !important; overflow: visible !important;
} }
.app .widgetCard-clock.widgetCardEditMode .widgetContent { .app .widgetCard-clock.widgetEditing .widgetContent {
height: calc(100% - 24px) !important; height: calc(100% - 24px) !important;
padding-top: 2px !important; padding-top: 2px !important;
} }
@@ -437,7 +435,7 @@
overflow: hidden !important; overflow: hidden !important;
} }
.app .widgetCard-clock.widgetCardEditMode .widgetContent { .app .widgetCard-clock.widgetEditing .widgetContent {
height: calc(100% - 24px) !important; height: calc(100% - 24px) !important;
padding: 0 !important; padding: 0 !important;
} }
@@ -459,7 +457,7 @@
overflow: hidden !important; overflow: hidden !important;
} }
.app .widgetCard-clock.widgetCardEditMode .widgetContent { .app .widgetCard-clock.widgetEditing .widgetContent {
height: calc(100% - 24px) !important; height: calc(100% - 24px) !important;
padding: 0 !important; padding: 0 !important;
} }
+4
View File
@@ -43,6 +43,10 @@ function getWidgetMinimumSize(widget: DashboardGridWidget): { minW: number; minH
return { minW: 4, minH: 5 }; return { minW: 4, minH: 5 };
} }
if (widget.type === "stopwatch") {
return { minW: 6, minH: 8 };
}
return { minW: 4, minH: 4 }; return { minW: 4, minH: 4 };
} }
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type Lap = {
number: number;
splitMs: number;
totalMs: number;
};
function formatMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "0")}`;
}
export default function StopwatchWidget() {
const [elapsedMs, setElapsedMs] = useState(0);
const [running, setRunning] = useState(false);
const [laps, setLaps] = useState<Lap[]>([]);
const startTimeRef = useRef(0);
const accumulatedRef = useRef(0);
const frameRef = useRef(0);
const lapsEndRef = useRef<HTMLDivElement | null>(null);
const tick = useCallback(() => {
setElapsedMs(accumulatedRef.current + (performance.now() - startTimeRef.current));
frameRef.current = requestAnimationFrame(tick);
}, []);
useEffect(() => {
if (running) {
startTimeRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
}
return () => cancelAnimationFrame(frameRef.current);
}, [running, tick]);
function start() {
setRunning(true);
}
function stop() {
accumulatedRef.current += performance.now() - startTimeRef.current;
setElapsedMs(accumulatedRef.current);
setRunning(false);
}
function reset() {
cancelAnimationFrame(frameRef.current);
accumulatedRef.current = 0;
startTimeRef.current = 0;
setElapsedMs(0);
setRunning(false);
setLaps([]);
}
function lap() {
const currentMs = accumulatedRef.current + (performance.now() - startTimeRef.current);
const previousTotal = laps.length > 0 ? laps[0].totalMs : 0;
setLaps((prev) => [
{ number: prev.length + 1, splitMs: currentMs - previousTotal, totalMs: currentMs },
...prev
]);
setTimeout(() => lapsEndRef.current?.scrollTo({ top: 0 }), 0);
}
const hasTime = elapsedMs > 0;
return (
<div className="stopwatchWidget">
<div className="stopwatchDisplay">{formatMs(elapsedMs)}</div>
<div className="stopwatchControls widgetNoDrag">
{!running && !hasTime ? (
<button type="button" className="stopwatchButton stopwatchStart" onClick={start}>
Start
</button>
) : null}
{running ? (
<>
<button type="button" className="stopwatchButton stopwatchLap" onClick={lap}>
Runde
</button>
<button type="button" className="stopwatchButton stopwatchStop" onClick={stop}>
Stopp
</button>
</>
) : null}
{!running && hasTime ? (
<>
<button type="button" className="stopwatchButton stopwatchReset" onClick={reset}>
Reset
</button>
<button type="button" className="stopwatchButton stopwatchStart" onClick={start}>
Weiter
</button>
</>
) : null}
</div>
{laps.length > 0 ? (
<div className="stopwatchLaps" ref={lapsEndRef}>
<table className="stopwatchLapTable">
<thead>
<tr>
<th>#</th>
<th>Runde</th>
<th>Gesamt</th>
</tr>
</thead>
<tbody>
{laps.map((l) => (
<tr key={l.number}>
<td className="stopwatchLapNumber">{l.number}</td>
<td className="stopwatchLapTime">{formatMs(l.splitMs)}</td>
<td className="stopwatchLapTotal">{formatMs(l.totalMs)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export type DashboardTab = {
position: number; position: number;
}; };
export type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check"; export type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check" | "stopwatch";
export type Widget = DashboardGridWidget & { export type Widget = DashboardGridWidget & {
userId: string; userId: string;