Compare commits
14 Commits
a22f1baf4d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e6b81bef3c | |||
| 001bfb3a0c | |||
| 30316e2353 | |||
| 80bd18a630 | |||
| 09a0e44b30 | |||
| b82e0e68c5 | |||
| 04d14b3aea | |||
| ca76bf315c | |||
| 88614b080a | |||
| d277789aa6 | |||
| a63c1069dd | |||
| 0db81aa715 | |||
| ec00024615 | |||
| 2f3e1bff1b |
@@ -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>
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+306
-140
@@ -53,7 +53,6 @@ button:disabled {
|
|||||||
|
|
||||||
.app {
|
.app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--background);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,14 +303,9 @@ button:disabled {
|
|||||||
|
|
||||||
.react-grid-item.react-draggable-dragging,
|
.react-grid-item.react-draggable-dragging,
|
||||||
.react-grid-item.resizing {
|
.react-grid-item.resizing {
|
||||||
z-index: 200;
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gridItemMenuOpen {
|
|
||||||
z-index: 250 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item > .react-resizable-handle {
|
.react-grid-item > .react-resizable-handle {
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
@@ -403,20 +397,11 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widgetMenu {
|
.widgetMenu {
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetMenuButton {
|
.widgetMenuButton {
|
||||||
height: 30px;
|
|
||||||
padding: 0 9px;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-strong);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 9px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetMenuButton:hover,
|
.widgetMenuButton:hover,
|
||||||
@@ -425,18 +410,10 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widgetDropdown {
|
.widgetDropdown {
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
right: 0;
|
|
||||||
z-index: 350;
|
|
||||||
width: 190px;
|
width: 190px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
|
||||||
padding: 10px;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetDropdownButton {
|
.widgetDropdownButton {
|
||||||
@@ -924,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);
|
||||||
@@ -1408,31 +1421,6 @@ button:disabled {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
--dashboard-background-image: none;
|
|
||||||
--dashboard-background-opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
background-image: var(--dashboard-background-image);
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
opacity: var(--dashboard-background-opacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app > * {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsSubPanel {
|
.settingsSubPanel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -1522,56 +1510,21 @@ button:disabled {
|
|||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboardWorkspace,
|
|
||||||
.widgetGridShell,
|
|
||||||
.emptyDashboard {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app::before {
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app > * {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app > .topBar,
|
.app > .topBar,
|
||||||
.app > .adminTopBar {
|
.app > .adminTopBar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5000;
|
z-index: 5000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topBar .profileMenu {
|
|
||||||
position: relative;
|
|
||||||
z-index: 5100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topBar .profileDropdown {
|
.topBar .profileDropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5200;
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboardWorkspace,
|
.widgetGrid {
|
||||||
.widgetGridShell,
|
|
||||||
.widgetGrid,
|
|
||||||
.emptyDashboard,
|
|
||||||
.react-grid-layout,
|
|
||||||
.react-grid-item {
|
|
||||||
z-index: auto;
|
z-index: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-grid-item.react-draggable-dragging,
|
|
||||||
.react-grid-item.resizing {
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridItemMenuOpen {
|
|
||||||
z-index: 250 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetCard-search .searchWidgetForm {
|
.widgetCard-search .searchWidgetForm {
|
||||||
grid-template-columns: minmax(82px, 130px) minmax(0, 1fr) auto !important;
|
grid-template-columns: minmax(82px, 130px) minmax(0, 1fr) auto !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1805,6 +1758,8 @@ button:disabled {
|
|||||||
.app {
|
.app {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
--dashboard-background-image: none;
|
||||||
|
--dashboard-background-opacity: 0;
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2472,33 +2427,6 @@ button:disabled {
|
|||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Widget-Menüs immer über anderen Widgets anzeigen */
|
|
||||||
.react-grid-item {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.gridItemMenuOpen {
|
|
||||||
z-index: 99999 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridItemMenuOpen,
|
|
||||||
.gridItemMenuOpen .widgetCard,
|
|
||||||
.gridItemMenuOpen .widgetHeader,
|
|
||||||
.gridItemMenuOpen .widgetMenu {
|
|
||||||
overflow: visible !important;
|
|
||||||
z-index: 99999 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetMenu {
|
|
||||||
position: relative !important;
|
|
||||||
z-index: 100000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetDropdown {
|
|
||||||
position: absolute !important;
|
|
||||||
z-index: 100001 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Suche-Widget: im normalen Modus ohne Rahmen/Header, im Bearbeitungsmodus aber mit Ziehen/Menü */
|
/* Suche-Widget: im normalen Modus ohne Rahmen/Header, im Bearbeitungsmodus aber mit Ziehen/Menü */
|
||||||
.widgetCard-search {
|
.widgetCard-search {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
@@ -2791,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;
|
||||||
@@ -2839,16 +2772,6 @@ button:disabled {
|
|||||||
z-index: 99999 !important;
|
z-index: 99999 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetMenu {
|
|
||||||
position: relative !important;
|
|
||||||
z-index: 100000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgetDropdown {
|
|
||||||
position: absolute !important;
|
|
||||||
z-index: 100001 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix: Widget-Edit-Header sauber vertikal zentrieren */
|
/* Fix: Widget-Edit-Header sauber vertikal zentrieren */
|
||||||
.widgetCard.widgetEditing .widgetHeader {
|
.widgetCard.widgetEditing .widgetHeader {
|
||||||
min-height: 44px !important;
|
min-height: 44px !important;
|
||||||
@@ -2887,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;
|
||||||
@@ -2940,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;
|
||||||
@@ -2948,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;
|
||||||
@@ -3194,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3224,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 */
|
||||||
@@ -3388,3 +3309,248 @@ button:disabled {
|
|||||||
padding: 6px 7px !important;
|
padding: 6px 7px !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Toolbar Fixes (merged from toolbar-fixes.css) --- */
|
||||||
|
|
||||||
|
.editToolbar .button:first-child,
|
||||||
|
.editToolbar button:first-child {
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editToolbar button,
|
||||||
|
.editToolbar .button {
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editToolbar .topEditModeButton,
|
||||||
|
.editToolbar button[aria-label="Bearbeitungsmodus aktivieren"],
|
||||||
|
.editToolbar button[aria-label="Bearbeitungsmodus deaktivieren"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar .profileMenu {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar .profileDropdown {
|
||||||
|
z-index: 5500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar .topEditModeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 78px;
|
||||||
|
z-index: 5350;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topEditModeButton {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topEditModeButton:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topEditModeButtonActive {
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIcon {
|
||||||
|
position: relative;
|
||||||
|
width: 22px;
|
||||||
|
height: 24px;
|
||||||
|
display: block;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIconBody {
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 13px;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIconBody::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 4px;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIconBody::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 7px;
|
||||||
|
width: 2px;
|
||||||
|
height: 4px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIconShackle {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 11px;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIconClosed .lockIconShackle {
|
||||||
|
left: 5px;
|
||||||
|
top: 1px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIconOpen .lockIconShackle {
|
||||||
|
left: 9px;
|
||||||
|
top: 1px;
|
||||||
|
transform: rotate(35deg);
|
||||||
|
transform-origin: left bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardWorkspace,
|
||||||
|
.widgetGridShell,
|
||||||
|
.emptyDashboard,
|
||||||
|
.react-grid-layout,
|
||||||
|
.react-grid-item {
|
||||||
|
z-index: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-draggable-dragging,
|
||||||
|
.react-grid-item.resizing {
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridItemMenuOpen {
|
||||||
|
z-index: 400 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topBar .topEditModeButton {
|
||||||
|
right: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topEditModeButton {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockIcon {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- User Theme (merged from user-theme.css) --- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent-text-fallback: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
--accent-text: var(--accent-text, var(--accent-text-fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.adminInlineButton,
|
||||||
|
.favoriteAddButton,
|
||||||
|
.searchWidgetForm .button {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 70%, var(--border) 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:not(.buttonSecondary):not(.favoriteRemoveButton):not(.logoutButton),
|
||||||
|
.adminInlineButton,
|
||||||
|
.favoriteAddButton,
|
||||||
|
.searchWidgetForm .button {
|
||||||
|
color: var(--accent-text, #ffffff);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:not(.buttonSecondary):not(.favoriteRemoveButton):not(.logoutButton):hover,
|
||||||
|
.adminInlineButton:hover,
|
||||||
|
.favoriteAddButton:hover,
|
||||||
|
.searchWidgetForm .button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary,
|
||||||
|
.profileDropdown .button,
|
||||||
|
.widgetMenuButton,
|
||||||
|
.widgetDragHandle {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 42%, var(--surface-strong) 58%);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 28%, var(--border) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary:hover,
|
||||||
|
.profileDropdown .button:hover,
|
||||||
|
.widgetMenuButton:hover,
|
||||||
|
.widgetDragHandle:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.select:focus,
|
||||||
|
textarea:focus,
|
||||||
|
.fileInput:focus,
|
||||||
|
.colorInput:focus {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topEditModeButtonActive {
|
||||||
|
color: var(--accent-text, #ffffff);
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favoriteTile:hover,
|
||||||
|
.widgetCard:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 65%, var(--border) 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favoriteIconPlaceholder {
|
||||||
|
color: var(--accent-text, #ffffff);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalsButton {
|
||||||
|
color: var(--accent-text, #ffffff) !important;
|
||||||
|
background: var(--accent) !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|||||||
+1
-2
@@ -8,11 +8,10 @@ import "./favorites-widget.css";
|
|||||||
import "./admin.css";
|
import "./admin.css";
|
||||||
import "./note-widget.css";
|
import "./note-widget.css";
|
||||||
import "./calendar-scale.css";
|
import "./calendar-scale.css";
|
||||||
import "./toolbar-fixes.css";
|
|
||||||
import "./user-theme.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",
|
||||||
|
|||||||
+63
-126
@@ -6,12 +6,14 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { DashboardGridProps } from "@/components/DashboardGrid";
|
import type { DashboardGridProps } from "@/components/DashboardGrid";
|
||||||
import type { DashboardGridWidget, DashboardLayoutItem } from "@/lib/dashboard-layout";
|
import type { DashboardGridWidget, DashboardLayoutItem } from "@/lib/dashboard-layout";
|
||||||
import { sortLayoutForPosition } from "@/lib/dashboard-layout";
|
import { sortLayoutForPosition } from "@/lib/dashboard-layout";
|
||||||
|
import type { User, Settings, DashboardTab, WidgetType, Widget, SearchProvider, CalendarEvent, CalendarSource, CalendarWidgetCalendarConfig, NoteBoardItem } from "@/types/dashboard";
|
||||||
import CalculatorWidget from "@/components/CalculatorWidget";
|
import CalculatorWidget from "@/components/CalculatorWidget";
|
||||||
import CalendarWidget from "@/components/CalendarWidget";
|
import CalendarWidget from "@/components/CalendarWidget";
|
||||||
import ClockWidget from "@/components/ClockWidget";
|
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,
|
||||||
@@ -25,95 +27,6 @@ const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/Das
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
type User = {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
displayName: string | null;
|
|
||||||
role: "ADMIN" | "USER";
|
|
||||||
};
|
|
||||||
|
|
||||||
type Settings = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
darkMode: boolean;
|
|
||||||
calendarIcsUrl: string | null;
|
|
||||||
calendarMaxEvents: number;
|
|
||||||
calendarLookaheadDays: number;
|
|
||||||
dashboardTitle: string;
|
|
||||||
dashboardSubtitle: string | null;
|
|
||||||
logoUrl: string | null;
|
|
||||||
backgroundImageUrl: string | null;
|
|
||||||
backgroundImageOpacity: number;
|
|
||||||
primaryColor: string;
|
|
||||||
secondaryColor: string;
|
|
||||||
customCss: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DashboardTab = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
title: string;
|
|
||||||
position: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check";
|
|
||||||
|
|
||||||
type Widget = DashboardGridWidget & {
|
|
||||||
userId: string;
|
|
||||||
viewMode?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchProvider = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
urlTemplate: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarEvent = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
start: string;
|
|
||||||
end: string | null;
|
|
||||||
location: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarSourceType = "ICS" | "EXCHANGE_EWS";
|
|
||||||
|
|
||||||
type CalendarSource = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
type: CalendarSourceType;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
nextEventsCount: number;
|
|
||||||
icsUrl: string | null;
|
|
||||||
exchangeEwsUrl: string | null;
|
|
||||||
exchangeMailbox: string | null;
|
|
||||||
exchangeUsername: string | null;
|
|
||||||
exchangeDomain: string | null;
|
|
||||||
passwordConfigured: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarWidgetCalendarConfig = {
|
|
||||||
sources: CalendarSource[];
|
|
||||||
selectedSourceIds: string[];
|
|
||||||
nextEventsCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NoteBoardItem = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
type: "note" | string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchProviders: SearchProvider[] = [
|
const searchProviders: SearchProvider[] = [
|
||||||
{
|
{
|
||||||
id: "google",
|
id: "google",
|
||||||
@@ -168,6 +81,10 @@ const widgetCatalog: Array<{
|
|||||||
{
|
{
|
||||||
type: "domain-check",
|
type: "domain-check",
|
||||||
title: "Domainprüfung"
|
title: "Domainprüfung"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "stopwatch",
|
||||||
|
title: "Stoppuhr"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -464,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)}`, {
|
||||||
@@ -522,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.");
|
||||||
}
|
}
|
||||||
@@ -1282,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1438,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
@@ -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:
|
||||||
|
|||||||
@@ -1,41 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import type { Settings, CalendarSource, CalendarSourceType } from "@/types/dashboard";
|
||||||
type Settings = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
darkMode: boolean;
|
|
||||||
calendarIcsUrl: string | null;
|
|
||||||
calendarMaxEvents: number;
|
|
||||||
calendarLookaheadDays: number;
|
|
||||||
dashboardTitle: string;
|
|
||||||
dashboardSubtitle: string | null;
|
|
||||||
logoUrl: string | null;
|
|
||||||
faviconUrl: string | null;
|
|
||||||
backgroundImageUrl: string | null;
|
|
||||||
backgroundImageOpacity: number;
|
|
||||||
primaryColor: string;
|
|
||||||
secondaryColor: string;
|
|
||||||
customCss: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarSourceType = "ICS" | "EXCHANGE_EWS";
|
|
||||||
|
|
||||||
type CalendarSource = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
type: CalendarSourceType;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
nextEventsCount: number;
|
|
||||||
icsUrl: string | null;
|
|
||||||
exchangeEwsUrl: string | null;
|
|
||||||
exchangeMailbox: string | null;
|
|
||||||
exchangeUsername: string | null;
|
|
||||||
exchangeDomain: string | null;
|
|
||||||
passwordConfigured: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarSourceDraft = {
|
type CalendarSourceDraft = {
|
||||||
sourceId: string | null;
|
sourceId: string | null;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
.editToolbar .button:first-child,
|
|
||||||
.editToolbar button:first-child {
|
|
||||||
display: inline-flex !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editToolbar button,
|
|
||||||
.editToolbar .button {
|
|
||||||
visibility: visible !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
pointer-events: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editToolbar .topEditModeButton,
|
|
||||||
.editToolbar button[aria-label="Bearbeitungsmodus aktivieren"],
|
|
||||||
.editToolbar button[aria-label="Bearbeitungsmodus deaktivieren"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topBar {
|
|
||||||
position: relative;
|
|
||||||
z-index: 5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topBar .profileMenu {
|
|
||||||
position: relative;
|
|
||||||
z-index: 5400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topBar .profileDropdown {
|
|
||||||
z-index: 5500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topBar .topEditModeButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 78px;
|
|
||||||
z-index: 5350;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topEditModeButton {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
display: inline-grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-strong);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
font-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topEditModeButton:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topEditModeButtonActive {
|
|
||||||
color: var(--accent-text);
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIcon {
|
|
||||||
position: relative;
|
|
||||||
width: 22px;
|
|
||||||
height: 24px;
|
|
||||||
display: block;
|
|
||||||
color: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIconBody {
|
|
||||||
position: absolute;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 2px;
|
|
||||||
width: 16px;
|
|
||||||
height: 13px;
|
|
||||||
background: transparent;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIconBody::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 4px;
|
|
||||||
width: 3px;
|
|
||||||
height: 3px;
|
|
||||||
background: currentColor;
|
|
||||||
border-radius: 999px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIconBody::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 7px;
|
|
||||||
width: 2px;
|
|
||||||
height: 4px;
|
|
||||||
background: currentColor;
|
|
||||||
border-radius: 999px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIconShackle {
|
|
||||||
position: absolute;
|
|
||||||
width: 12px;
|
|
||||||
height: 11px;
|
|
||||||
background: transparent;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-bottom: 0;
|
|
||||||
border-radius: 10px 10px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIconClosed .lockIconShackle {
|
|
||||||
left: 5px;
|
|
||||||
top: 1px;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIconOpen .lockIconShackle {
|
|
||||||
left: 9px;
|
|
||||||
top: 1px;
|
|
||||||
transform: rotate(35deg);
|
|
||||||
transform-origin: left bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboardWorkspace,
|
|
||||||
.widgetGridShell,
|
|
||||||
.emptyDashboard,
|
|
||||||
.react-grid-layout,
|
|
||||||
.react-grid-item {
|
|
||||||
z-index: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.react-draggable-dragging,
|
|
||||||
.react-grid-item.resizing {
|
|
||||||
z-index: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridItemMenuOpen {
|
|
||||||
z-index: 400 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.topBar .topEditModeButton {
|
|
||||||
right: 72px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topEditModeButton {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockIcon {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
:root {
|
|
||||||
--accent-text-fallback: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
--accent-text: var(--accent-text, var(--accent-text-fallback));
|
|
||||||
}
|
|
||||||
|
|
||||||
.button,
|
|
||||||
.adminInlineButton,
|
|
||||||
.favoriteAddButton,
|
|
||||||
.searchWidgetForm .button {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--border) 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:not(.buttonSecondary):not(.favoriteRemoveButton):not(.logoutButton),
|
|
||||||
.adminInlineButton,
|
|
||||||
.favoriteAddButton,
|
|
||||||
.searchWidgetForm .button {
|
|
||||||
color: var(--accent-text, #ffffff);
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:not(.buttonSecondary):not(.favoriteRemoveButton):not(.logoutButton):hover,
|
|
||||||
.adminInlineButton:hover,
|
|
||||||
.favoriteAddButton:hover,
|
|
||||||
.searchWidgetForm .button:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonSecondary,
|
|
||||||
.profileDropdown .button,
|
|
||||||
.widgetMenuButton,
|
|
||||||
.widgetDragHandle {
|
|
||||||
color: var(--text);
|
|
||||||
background: color-mix(in srgb, var(--accent-soft) 42%, var(--surface-strong) 58%);
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 28%, var(--border) 72%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonSecondary:hover,
|
|
||||||
.profileDropdown .button:hover,
|
|
||||||
.widgetMenuButton:hover,
|
|
||||||
.widgetDragHandle:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus,
|
|
||||||
.select:focus,
|
|
||||||
textarea:focus,
|
|
||||||
.fileInput:focus,
|
|
||||||
.colorInput:focus {
|
|
||||||
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topEditModeButtonActive {
|
|
||||||
color: var(--accent-text, #ffffff);
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteTile:hover,
|
|
||||||
.widgetCard:hover {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 65%, var(--border) 35%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.favoriteIconPlaceholder {
|
|
||||||
color: var(--accent-text, #ffffff);
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalsButton {
|
|
||||||
color: var(--accent-text, #ffffff) !important;
|
|
||||||
background: var(--accent) !important;
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import type { CalendarEvent, CalendarSource } from "@/types/dashboard";
|
||||||
type CalendarEvent = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
start: string;
|
|
||||||
end: string | null;
|
|
||||||
location: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarSource = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
type: string;
|
|
||||||
passwordConfigured: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarWidgetProps = {
|
type CalendarWidgetProps = {
|
||||||
widgetId: string;
|
widgetId: string;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { NoteBoardItem } from "@/types/dashboard";
|
||||||
type NoteBoardItem = {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
type: "note" | string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NoteWidgetProps = {
|
type NoteWidgetProps = {
|
||||||
note: NoteBoardItem | null;
|
note: NoteBoardItem | null;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { DashboardGridWidget } from "@/lib/dashboard-layout";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string | null;
|
||||||
|
role: "ADMIN" | "USER";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
darkMode: boolean;
|
||||||
|
calendarIcsUrl: string | null;
|
||||||
|
calendarMaxEvents: number;
|
||||||
|
calendarLookaheadDays: number;
|
||||||
|
dashboardTitle: string;
|
||||||
|
dashboardSubtitle: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
faviconUrl: string | null;
|
||||||
|
backgroundImageUrl: string | null;
|
||||||
|
backgroundImageOpacity: number;
|
||||||
|
primaryColor: string;
|
||||||
|
secondaryColor: string;
|
||||||
|
customCss: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardTab = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check" | "stopwatch";
|
||||||
|
|
||||||
|
export type Widget = DashboardGridWidget & {
|
||||||
|
userId: string;
|
||||||
|
viewMode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchProvider = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
urlTemplate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
location: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarSourceType = "ICS" | "EXCHANGE_EWS";
|
||||||
|
|
||||||
|
export type CalendarSource = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: CalendarSourceType;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
nextEventsCount: number;
|
||||||
|
icsUrl: string | null;
|
||||||
|
exchangeEwsUrl: string | null;
|
||||||
|
exchangeMailbox: string | null;
|
||||||
|
exchangeUsername: string | null;
|
||||||
|
exchangeDomain: string | null;
|
||||||
|
passwordConfigured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarWidgetCalendarConfig = {
|
||||||
|
sources: CalendarSource[];
|
||||||
|
selectedSourceIds: string[];
|
||||||
|
nextEventsCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteBoardItem = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: "note" | string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user