Extract NoteWidget and CalendarWidget from monolithic page.tsx
Split page.tsx from ~2370 to ~1600 lines by extracting: - NoteWidget: Markdown editor/preview, toolbar, checkbox toggle, all formatting helpers (bold, italic, lists, links, code) - CalendarWidget: Month grid, event tooltips, source selection, next-events list, month navigation Both components are self-contained with their own state where appropriate (calendar month navigation, local content edits) while receiving data and callbacks from the parent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
-800
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DashboardGridProps } from "@/components/DashboardGrid";
|
||||
import type { DashboardGridWidget, DashboardLayoutItem } from "@/lib/dashboard-layout";
|
||||
import { sortLayoutForPosition } from "@/lib/dashboard-layout";
|
||||
import CalculatorWidget from "@/components/CalculatorWidget";
|
||||
import CalendarWidget from "@/components/CalendarWidget";
|
||||
import ClockWidget from "@/components/ClockWidget";
|
||||
import FavoritesWidget from "@/components/FavoritesWidget";
|
||||
import DomainCheckWidget from "@/components/DomainCheckWidget";
|
||||
import FavoritesWidget from "@/components/FavoritesWidget";
|
||||
import NoteWidget from "@/components/NoteWidget";
|
||||
|
||||
const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), {
|
||||
ssr: false,
|
||||
@@ -98,14 +100,6 @@ type CalendarWidgetCalendarConfig = {
|
||||
nextEventsCount: number;
|
||||
};
|
||||
|
||||
type CalendarDay = {
|
||||
key: string;
|
||||
date: Date;
|
||||
inCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
type NoteBoardItem = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -177,40 +171,6 @@ const widgetCatalog: Array<{
|
||||
}
|
||||
];
|
||||
|
||||
const weekdayLabels = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatEventDate(value: string): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatEventTime(value: string): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatMonthLabel(value: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function getUserInitials(user: User): string {
|
||||
const source = user.displayName?.trim() || user.email.trim();
|
||||
|
||||
@@ -227,37 +187,6 @@ function getUserInitials(user: User): string {
|
||||
return source.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function buildCalendarDays(monthDate: Date, eventsByDate: Map<string, CalendarEvent[]>): CalendarDay[] {
|
||||
const year = monthDate.getFullYear();
|
||||
const month = monthDate.getMonth();
|
||||
const firstDayOfMonth = new Date(year, month, 1);
|
||||
const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7;
|
||||
|
||||
const gridStart = new Date(firstDayOfMonth);
|
||||
gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset);
|
||||
gridStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const today = dateKey(new Date());
|
||||
const days: CalendarDay[] = [];
|
||||
|
||||
for (let index = 0; index < 42; index += 1) {
|
||||
const date = new Date(gridStart);
|
||||
date.setDate(gridStart.getDate() + index);
|
||||
|
||||
const key = dateKey(date);
|
||||
|
||||
days.push({
|
||||
key,
|
||||
date,
|
||||
inCurrentMonth: date.getMonth() === month,
|
||||
isToday: key === today,
|
||||
events: eventsByDate.get(key) ?? []
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -330,8 +259,6 @@ export default function DashboardPage() {
|
||||
const [calendarSources, setCalendarSources] = useState<CalendarSource[]>([]);
|
||||
const [calendarSelectionsByWidget, setCalendarSelectionsByWidget] = useState<Record<string, string[]>>({});
|
||||
const [calendarNextEventsCountByWidget, setCalendarNextEventsCountByWidget] = useState<Record<string, number>>({});
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||
const [openWidgetMenuId, setOpenWidgetMenuId] = useState<string | null>(null);
|
||||
@@ -378,19 +305,6 @@ export default function DashboardPage() {
|
||||
const dashboardSubtitle = settings?.dashboardSubtitle?.trim() || user?.email || "";
|
||||
const logoUrl = settings?.logoUrl?.trim() || "/logo.svg";
|
||||
|
||||
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const groupedEvents = new Map<string, CalendarEvent[]>();
|
||||
|
||||
events.forEach((event) => {
|
||||
const key = dateKey(new Date(event.start));
|
||||
const existingEvents = groupedEvents.get(key) ?? [];
|
||||
|
||||
groupedEvents.set(key, [...existingEvents, event]);
|
||||
});
|
||||
|
||||
return groupedEvents;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
@@ -956,18 +870,6 @@ export default function DashboardPage() {
|
||||
setProfileMenuOpen(false);
|
||||
}
|
||||
|
||||
function showPreviousMonth() {
|
||||
setCalendarMonth((current) => new Date(current.getFullYear(), current.getMonth() - 1, 1));
|
||||
}
|
||||
|
||||
function showNextMonth() {
|
||||
setCalendarMonth((current) => new Date(current.getFullYear(), current.getMonth() + 1, 1));
|
||||
}
|
||||
|
||||
function showCurrentMonth() {
|
||||
setCalendarMonth(new Date());
|
||||
}
|
||||
|
||||
function renderNoteEditButton(widget: Widget) {
|
||||
if (widget.type !== "note" && widget.type !== "noteboard") {
|
||||
return null;
|
||||
@@ -1195,548 +1097,6 @@ export default function DashboardPage() {
|
||||
return notes.find((note) => note.id === widget.id) ?? null;
|
||||
}
|
||||
|
||||
function getNoteTextarea(noteId: string): HTMLTextAreaElement | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return document.getElementById(`note-textarea-${noteId}`) as HTMLTextAreaElement | null;
|
||||
}
|
||||
|
||||
function updateNoteContentFromToolbar(
|
||||
note: NoteBoardItem,
|
||||
nextContent: string,
|
||||
nextSelectionStart: number,
|
||||
nextSelectionEnd = nextSelectionStart
|
||||
) {
|
||||
setNotes((current) =>
|
||||
current.map((currentNote) =>
|
||||
currentNote.id === note.id
|
||||
? {
|
||||
...currentNote,
|
||||
content: nextContent
|
||||
}
|
||||
: currentNote
|
||||
)
|
||||
);
|
||||
|
||||
void patchNote(note.id, {
|
||||
content: nextContent
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextSelectionStart, nextSelectionEnd);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function wrapNoteSelection(note: NoteBoardItem, before: string, after: string, placeholder: string) {
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
const value = note.content ?? "";
|
||||
const start = textarea?.selectionStart ?? value.length;
|
||||
const end = textarea?.selectionEnd ?? value.length;
|
||||
const selectedText = value.slice(start, end);
|
||||
|
||||
const beforeSelection = value.slice(Math.max(0, start - before.length), start);
|
||||
const afterSelection = value.slice(end, end + after.length);
|
||||
|
||||
if (selectedText && beforeSelection === before && afterSelection === after) {
|
||||
const nextContent = `${value.slice(0, start - before.length)}${selectedText}${value.slice(end + after.length)}`;
|
||||
const nextStart = start - before.length;
|
||||
const nextEnd = nextStart + selectedText.length;
|
||||
|
||||
updateNoteContentFromToolbar(note, nextContent, nextStart, nextEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedText.startsWith(before) && selectedText.endsWith(after) && selectedText.length > before.length + after.length) {
|
||||
const unwrappedText = selectedText.slice(before.length, selectedText.length - after.length);
|
||||
const nextContent = `${value.slice(0, start)}${unwrappedText}${value.slice(end)}`;
|
||||
|
||||
updateNoteContentFromToolbar(note, nextContent, start, start + unwrappedText.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const insertedText = selectedText || placeholder;
|
||||
const replacement = `${before}${insertedText}${after}`;
|
||||
const nextContent = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||
const selectionStart = start + before.length;
|
||||
const selectionEnd = selectionStart + insertedText.length;
|
||||
|
||||
updateNoteContentFromToolbar(note, nextContent, selectionStart, selectionEnd);
|
||||
}
|
||||
|
||||
function prefixNoteLines(note: NoteBoardItem, prefix: string, placeholder: string) {
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
const value = note.content ?? "";
|
||||
const start = textarea?.selectionStart ?? value.length;
|
||||
const end = textarea?.selectionEnd ?? value.length;
|
||||
const lineStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
||||
const lineEndCandidate = end === start ? end : end;
|
||||
const nextLineBreak = value.indexOf("\n", lineEndCandidate);
|
||||
const lineEnd = nextLineBreak === -1 || end === start ? lineEndCandidate : lineEndCandidate;
|
||||
const selectedBlock = value.slice(lineStart, lineEnd) || placeholder;
|
||||
const lines = selectedBlock.split("\n");
|
||||
|
||||
const isCheckboxMode = prefix === "- [ ] ";
|
||||
const isOrderedMode = prefix === "1. ";
|
||||
const isBulletMode = prefix === "- ";
|
||||
|
||||
const checkboxPattern = /^(\s*)[-*]\s+\[[ xX]\]\s+/;
|
||||
const orderedPattern = /^(\s*)\d+\.\s+/;
|
||||
const bulletPattern = /^(\s*)[-*]\s+(?!\[[ xX]\]\s+)/;
|
||||
|
||||
const alreadyFormatted = lines.every((line) => {
|
||||
if (!line.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isCheckboxMode) {
|
||||
return checkboxPattern.test(line);
|
||||
}
|
||||
|
||||
if (isOrderedMode) {
|
||||
return orderedPattern.test(line);
|
||||
}
|
||||
|
||||
if (isBulletMode) {
|
||||
return bulletPattern.test(line);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const nextLines = lines.map((line, index) => {
|
||||
if (!line.trim()) {
|
||||
return line;
|
||||
}
|
||||
|
||||
if (alreadyFormatted) {
|
||||
if (isCheckboxMode) {
|
||||
return line.replace(checkboxPattern, "$1");
|
||||
}
|
||||
|
||||
if (isOrderedMode) {
|
||||
return line.replace(orderedPattern, "$1");
|
||||
}
|
||||
|
||||
if (isBulletMode) {
|
||||
return line.replace(bulletPattern, "$1");
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
const cleanLine = line
|
||||
.replace(checkboxPattern, "$1")
|
||||
.replace(orderedPattern, "$1")
|
||||
.replace(bulletPattern, "$1");
|
||||
|
||||
if (isOrderedMode) {
|
||||
return cleanLine.replace(/^(\s*)/, `$1${index + 1}. `);
|
||||
}
|
||||
|
||||
return cleanLine.replace(/^(\s*)/, `$1${prefix}`);
|
||||
});
|
||||
|
||||
const prefixedBlock = nextLines.join("\n");
|
||||
const nextContent = `${value.slice(0, lineStart)}${prefixedBlock}${value.slice(lineEnd)}`;
|
||||
|
||||
updateNoteContentFromToolbar(note, nextContent, lineStart, lineStart + prefixedBlock.length);
|
||||
}
|
||||
|
||||
function insertNoteLink(note: NoteBoardItem) {
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
const value = note.content ?? "";
|
||||
const start = textarea?.selectionStart ?? value.length;
|
||||
const end = textarea?.selectionEnd ?? value.length;
|
||||
const selectedText = value.slice(start, end) || "Linktext";
|
||||
const replacement = `[${selectedText}](https://)`;
|
||||
const nextContent = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||
const urlStart = start + selectedText.length + 3;
|
||||
|
||||
updateNoteContentFromToolbar(note, nextContent, urlStart, urlStart + 8);
|
||||
}
|
||||
|
||||
function normalizeMarkdownHref(href: string): string {
|
||||
const cleanHref = href.trim();
|
||||
|
||||
if (cleanHref.startsWith("/") || cleanHref.startsWith("#")) {
|
||||
return cleanHref;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(cleanHref);
|
||||
|
||||
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:" || parsedUrl.protocol === "mailto:") {
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
} catch {
|
||||
return "#";
|
||||
}
|
||||
|
||||
return "#";
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
const pattern = /(\*\*[^*\n]+?\*\*|\*[^*\n]+?\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)]+?\))/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
|
||||
if (token.startsWith("**") && token.endsWith("**")) {
|
||||
nodes.push(<strong key={`strong-${key}`}>{token.slice(2, -2)}</strong>);
|
||||
} else if (token.startsWith("*") && token.endsWith("*")) {
|
||||
nodes.push(<em key={`em-${key}`}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith("`") && token.endsWith("`")) {
|
||||
nodes.push(<code key={`code-${key}`}>{token.slice(1, -1)}</code>);
|
||||
} else {
|
||||
const linkMatch = token.match(/^\[([^\]]+?)\]\(([^)]+?)\)$/);
|
||||
|
||||
if (linkMatch) {
|
||||
nodes.push(
|
||||
<a key={`link-${key}`} href={normalizeMarkdownHref(linkMatch[2])} target="_blank" rel="noreferrer">
|
||||
{linkMatch[1]}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
nodes.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
key += 1;
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function toggleMarkdownCheckbox(note: NoteBoardItem, lineIndex: number) {
|
||||
const lines = (note.content ?? "").split("\n");
|
||||
const line = lines[lineIndex] ?? "";
|
||||
const match = line.match(/^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isChecked = match[2].toLowerCase() === "x";
|
||||
lines[lineIndex] = `${match[1]}${isChecked ? " " : "x"}${match[3]}`;
|
||||
|
||||
const nextContent = lines.join("\n");
|
||||
|
||||
setNotes((current) =>
|
||||
current.map((currentNote) =>
|
||||
currentNote.id === note.id
|
||||
? {
|
||||
...currentNote,
|
||||
content: nextContent
|
||||
}
|
||||
: currentNote
|
||||
)
|
||||
);
|
||||
|
||||
void patchNote(note.id, {
|
||||
content: nextContent
|
||||
});
|
||||
}
|
||||
|
||||
function renderNoteMarkdownPreview(note: NoteBoardItem) {
|
||||
const content = note.content ?? "";
|
||||
|
||||
if (!content.trim()) {
|
||||
return <p className="muted noteEmptyText">Noch keine Notiz.</p>;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const blocks: ReactNode[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
if (!line.trim()) {
|
||||
blocks.push(<div className="noteMarkdownSpacer" key={`space-${index}`} />);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const className = `noteMarkdownHeading noteMarkdownHeading${level}`;
|
||||
|
||||
blocks.push(
|
||||
<div className={className} key={`heading-${index}`}>
|
||||
{renderInlineMarkdown(headingMatch[2])}
|
||||
</div>
|
||||
);
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskMatch = line.match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/);
|
||||
|
||||
if (taskMatch) {
|
||||
const tasks: Array<{ lineIndex: number; checked: boolean; text: string }> = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentMatch = lines[index].match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/);
|
||||
|
||||
if (!currentMatch) {
|
||||
break;
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
lineIndex: index,
|
||||
checked: currentMatch[2].toLowerCase() === "x",
|
||||
text: currentMatch[3]
|
||||
});
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<div className="noteMarkdownChecklist" key={`tasks-${tasks[0]?.lineIndex ?? index}`}>
|
||||
{tasks.map((task) => (
|
||||
<label className="noteMarkdownTask" key={`task-${task.lineIndex}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.checked}
|
||||
onChange={() => toggleMarkdownCheckbox(note, task.lineIndex)}
|
||||
/>
|
||||
<span>{renderInlineMarkdown(task.text)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedMatch = line.match(/^\s*[-*]\s+(?!\[[ xX]\]\s+)(.+)$/);
|
||||
|
||||
if (unorderedMatch) {
|
||||
const items: Array<{ lineIndex: number; text: string }> = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentMatch = lines[index].match(/^\s*[-*]\s+(?!\[[ xX]\]\s+)(.+)$/);
|
||||
|
||||
if (!currentMatch) {
|
||||
break;
|
||||
}
|
||||
|
||||
items.push({
|
||||
lineIndex: index,
|
||||
text: currentMatch[1]
|
||||
});
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<ul className="noteMarkdownList" key={`ul-${items[0]?.lineIndex ?? index}`}>
|
||||
{items.map((item) => (
|
||||
<li key={`ul-item-${item.lineIndex}`}>{renderInlineMarkdown(item.text)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
|
||||
if (orderedMatch) {
|
||||
const items: Array<{ lineIndex: number; text: string }> = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentMatch = lines[index].match(/^\s*\d+\.\s+(.+)$/);
|
||||
|
||||
if (!currentMatch) {
|
||||
break;
|
||||
}
|
||||
|
||||
items.push({
|
||||
lineIndex: index,
|
||||
text: currentMatch[1]
|
||||
});
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<ol className="noteMarkdownList" key={`ol-${items[0]?.lineIndex ?? index}`}>
|
||||
{items.map((item) => (
|
||||
<li key={`ol-item-${item.lineIndex}`}>{renderInlineMarkdown(item.text)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p className="noteMarkdownParagraph" key={`p-${index}`}>
|
||||
{renderInlineMarkdown(line)}
|
||||
</p>
|
||||
);
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return <div className="noteMarkdownPreview widgetNoDrag">{blocks}</div>;
|
||||
}
|
||||
|
||||
function renderNoteMarkdownToolbar(note: NoteBoardItem) {
|
||||
return (
|
||||
<div className="noteMarkdownToolbar widgetNoDrag" aria-label="Markdown-Werkzeuge">
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => wrapNoteSelection(note, "**", "**", "fett")}
|
||||
title="Fett"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => wrapNoteSelection(note, "*", "*", "kursiv")}
|
||||
title="Kursiv"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton noteMarkdownButtonWide"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => prefixNoteLines(note, "- ", "Eintrag")}
|
||||
title="Aufzählung"
|
||||
>
|
||||
• Liste
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton noteMarkdownButtonWide"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => prefixNoteLines(note, "1. ", "Eintrag")}
|
||||
title="Nummerierte Liste"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton noteMarkdownButtonWide"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => prefixNoteLines(note, "- [ ] ", "Aufgabe")}
|
||||
title="Checkbox"
|
||||
>
|
||||
☐
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => wrapNoteSelection(note, "`", "`", "Code")}
|
||||
title="Inline-Code"
|
||||
>
|
||||
{"</>"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="noteMarkdownButton noteMarkdownButtonWide"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => insertNoteLink(note)}
|
||||
title="Link"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNoteWidget(widget: Widget) {
|
||||
const note = getNoteForWidget(widget);
|
||||
|
||||
if (!note) {
|
||||
return (
|
||||
<div className="singleNoteWidget widgetNoDrag">
|
||||
{noteError ? <p className="errorText">{noteError}</p> : null}
|
||||
|
||||
<button type="button" className="button buttonSecondary" onClick={() => void createMissingNoteForWidget(widget)}>
|
||||
Eintrag initialisieren
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noteIsEditing = editMode || editingNoteWidgetId === widget.id;
|
||||
|
||||
if (!noteIsEditing) {
|
||||
return renderNoteMarkdownPreview(note);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="singleNoteWidget noteMarkdownEditor widgetNoDrag">
|
||||
{noteError ? <p className="errorText">{noteError}</p> : null}
|
||||
|
||||
{renderNoteMarkdownToolbar(note)}
|
||||
|
||||
<textarea
|
||||
id={`note-textarea-${note.id}`}
|
||||
className="noteTextarea singleNoteTextarea"
|
||||
value={note.content}
|
||||
onChange={(event) => {
|
||||
const nextContent = event.target.value;
|
||||
|
||||
setNotes((current) =>
|
||||
current.map((currentNote) =>
|
||||
currentNote.id === note.id
|
||||
? {
|
||||
...currentNote,
|
||||
content: nextContent
|
||||
}
|
||||
: currentNote
|
||||
)
|
||||
);
|
||||
}}
|
||||
onBlur={(event) => void patchNote(note.id, { content: event.target.value })}
|
||||
placeholder="Notiz schreiben... Markdown wird unterstützt."
|
||||
spellCheck={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function reloadCalendarWidget(widgetId: string) {
|
||||
const [configResponse, eventsResponse] = await Promise.all([
|
||||
fetch(`/api/calendar/source?widgetId=${encodeURIComponent(widgetId)}`, {
|
||||
@@ -1837,65 +1197,6 @@ export default function DashboardPage() {
|
||||
void saveCalendarWidgetSources(widgetId, selectedSourceIds, nextEventsCount);
|
||||
}
|
||||
|
||||
function renderCalendarSourceSettings(widget: Widget) {
|
||||
if (!editMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedSourceIds = calendarSelectionsByWidget[widget.id] ?? [];
|
||||
const nextEventsCount = calendarNextEventsCountByWidget[widget.id] ?? 3;
|
||||
|
||||
return (
|
||||
<details className="calendarSourcePanel widgetNoDrag">
|
||||
<summary>Kalenderauswahl</summary>
|
||||
|
||||
<div className="calendarSourceForm">
|
||||
{calendarSources.length === 0 ? (
|
||||
<p className="muted">
|
||||
Keine Kalenderquellen angelegt. Lege Kalender in den Benutzereinstellungen an.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{calendarSources.map((source) => (
|
||||
<label className="calendarSourceCheckbox" key={source.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSourceIds.includes(source.id)}
|
||||
onChange={() => toggleCalendarSourceForWidget(widget.id, source.id)}
|
||||
/>
|
||||
<span className="calendarSourceColorDot" style={{ background: source.color }} />
|
||||
<span>
|
||||
<strong>{source.name}</strong>
|
||||
<small>{source.type === "EXCHANGE_EWS" ? "Exchange" : "ICS"}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label className="fieldLabel">
|
||||
Termine unter dem Kalender
|
||||
<select
|
||||
className="select"
|
||||
value={nextEventsCount}
|
||||
onChange={(event) => updateCalendarWidgetNextEventsCount(widget.id, Number(event.target.value))}
|
||||
>
|
||||
<option value={0}>Ausblenden</option>
|
||||
<option value={1}>1 Termin</option>
|
||||
<option value={2}>2 Termine</option>
|
||||
<option value={3}>3 Termine</option>
|
||||
<option value={4}>4 Termine</option>
|
||||
<option value={5}>5 Termine</option>
|
||||
<option value={6}>6 Termine</option>
|
||||
<option value={7}>7 Termine</option>
|
||||
<option value={8}>8 Termine</option>
|
||||
<option value={9}>9 Termine</option>
|
||||
<option value={10}>10 Termine</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSearchWidget() {
|
||||
return (
|
||||
<form className="searchWidgetForm widgetNoDrag" onSubmit={handleSearch}>
|
||||
@@ -1922,101 +1223,6 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderCalendarWidget(widget: Widget) {
|
||||
const widgetEvents = calendarEventsByWidget[widget.id] ?? [];
|
||||
const widgetError = calendarErrorsByWidget[widget.id] ?? null;
|
||||
const selectedSourceIds = calendarSelectionsByWidget[widget.id] ?? [];
|
||||
const widgetEventsByDate = groupEventsByDate(widgetEvents);
|
||||
const widgetCalendarDays = buildCalendarDays(calendarMonth, widgetEventsByDate);
|
||||
const nextEventsCount = Math.max(0, Math.min(10, calendarNextEventsCountByWidget[widget.id] ?? 3));
|
||||
const nextWidgetEvents = [...widgetEvents]
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||
.slice(0, nextEventsCount);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderCalendarSourceSettings(widget)}
|
||||
|
||||
<div className="calendarHeader widgetNoDrag">
|
||||
<button type="button" className="calendarNavButton" onClick={showPreviousMonth}>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<button type="button" className="calendarMonthButton" onClick={showCurrentMonth}>
|
||||
{formatMonthLabel(calendarMonth)}
|
||||
</button>
|
||||
|
||||
<button type="button" className="calendarNavButton" onClick={showNextMonth}>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedSourceIds.length === 0 ? <p className="muted calendarStatus">Keine Kalenderquelle ausgewählt.</p> : null}
|
||||
|
||||
<div className="calendarWeekdays">
|
||||
{weekdayLabels.map((label) => (
|
||||
<div className="calendarWeekday" key={label}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="calendarGrid">
|
||||
{widgetCalendarDays.map((day) => (
|
||||
<div
|
||||
className={[
|
||||
"calendarDay",
|
||||
day.inCurrentMonth ? "" : "calendarDayMuted",
|
||||
day.isToday ? "calendarDayToday" : "",
|
||||
day.events.length > 0 ? "calendarDayWithEvents" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={day.key}
|
||||
>
|
||||
<span className="calendarDayNumber">{day.date.getDate()}</span>
|
||||
|
||||
{day.events.length > 0 ? <span className="calendarEventCount">{day.events.length}</span> : null}
|
||||
|
||||
{day.events.length > 0 ? (
|
||||
<div className="calendarTooltip">
|
||||
{day.events.slice(0, 5).map((event) => (
|
||||
<div className="calendarTooltipItem" key={event.id}>
|
||||
<span className="calendarTooltipTime">{formatEventTime(event.start)}</span>
|
||||
<span>{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{day.events.length > 5 ? <div className="calendarTooltipMore">Weitere Termine vorhanden</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{widgetError ? <p className="muted calendarStatus">{widgetError}</p> : null}
|
||||
|
||||
{nextEventsCount > 0 ? (
|
||||
<section className="nextEventsBlock">
|
||||
<h3>Nächste Termine</h3>
|
||||
|
||||
{nextWidgetEvents.length === 0 ? <p className="muted">Keine nächsten Termine.</p> : null}
|
||||
|
||||
<div className="eventList">
|
||||
{nextWidgetEvents.map((event) => (
|
||||
<article className="eventItem" key={event.id}>
|
||||
<div className="eventDate">{formatEventDate(event.start)}</div>
|
||||
<div className="eventTitle">{event.title}</div>
|
||||
{event.location ? <div className="eventLocation">{event.location}</div> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWidgetContent(widget: Widget) {
|
||||
if (widget.type === "favorites") {
|
||||
return (
|
||||
@@ -2030,7 +1236,18 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
if (widget.type === "note" || widget.type === "noteboard") {
|
||||
return renderNoteWidget(widget);
|
||||
const note = getNoteForWidget(widget);
|
||||
|
||||
return (
|
||||
<NoteWidget
|
||||
note={note}
|
||||
editMode={editMode}
|
||||
isEditing={editingNoteWidgetId === widget.id}
|
||||
noteError={noteError}
|
||||
onSave={(noteId, patch) => void patchNote(noteId, patch)}
|
||||
onInitialize={() => void createMissingNoteForWidget(widget)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === "search") {
|
||||
@@ -2046,7 +1263,19 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
if (widget.type === "calendar") {
|
||||
return renderCalendarWidget(widget);
|
||||
return (
|
||||
<CalendarWidget
|
||||
widgetId={widget.id}
|
||||
editMode={editMode}
|
||||
events={calendarEventsByWidget[widget.id] ?? []}
|
||||
error={calendarErrorsByWidget[widget.id] ?? null}
|
||||
sources={calendarSources}
|
||||
selectedSourceIds={calendarSelectionsByWidget[widget.id] ?? []}
|
||||
nextEventsCount={calendarNextEventsCountByWidget[widget.id] ?? 3}
|
||||
onToggleSource={(wId, sId) => toggleCalendarSourceForWidget(wId, sId)}
|
||||
onChangeEventsCount={(wId, count) => updateCalendarWidgetNextEventsCount(wId, count)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === "domain-check") {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
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 = {
|
||||
widgetId: string;
|
||||
editMode: boolean;
|
||||
events: CalendarEvent[];
|
||||
error: string | null;
|
||||
sources: CalendarSource[];
|
||||
selectedSourceIds: string[];
|
||||
nextEventsCount: number;
|
||||
onToggleSource: (widgetId: string, sourceId: string) => void;
|
||||
onChangeEventsCount: (widgetId: string, count: number) => void;
|
||||
};
|
||||
|
||||
type CalendarDay = {
|
||||
key: string;
|
||||
date: Date;
|
||||
inCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
const weekdayLabels = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatEventDate(value: string): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatEventTime(value: string): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatMonthLabel(value: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function buildCalendarDays(monthDate: Date, eventsByDate: Map<string, CalendarEvent[]>): CalendarDay[] {
|
||||
const year = monthDate.getFullYear();
|
||||
const month = monthDate.getMonth();
|
||||
const firstDayOfMonth = new Date(year, month, 1);
|
||||
const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7;
|
||||
|
||||
const gridStart = new Date(firstDayOfMonth);
|
||||
gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset);
|
||||
gridStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const today = dateKey(new Date());
|
||||
const days: CalendarDay[] = [];
|
||||
|
||||
for (let index = 0; index < 42; index += 1) {
|
||||
const date = new Date(gridStart);
|
||||
date.setDate(gridStart.getDate() + index);
|
||||
|
||||
const key = dateKey(date);
|
||||
|
||||
days.push({
|
||||
key,
|
||||
date,
|
||||
inCurrentMonth: date.getMonth() === month,
|
||||
isToday: key === today,
|
||||
events: eventsByDate.get(key) ?? []
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const grouped = new Map<string, CalendarEvent[]>();
|
||||
|
||||
events.forEach((event) => {
|
||||
const key = dateKey(new Date(event.start));
|
||||
const existing = grouped.get(key) ?? [];
|
||||
|
||||
grouped.set(key, [...existing, event]);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export default function CalendarWidget({
|
||||
widgetId,
|
||||
editMode,
|
||||
events,
|
||||
error,
|
||||
sources,
|
||||
selectedSourceIds,
|
||||
nextEventsCount,
|
||||
onToggleSource,
|
||||
onChangeEventsCount
|
||||
}: CalendarWidgetProps) {
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(events), [events]);
|
||||
const calendarDays = useMemo(() => buildCalendarDays(calendarMonth, eventsByDate), [calendarMonth, eventsByDate]);
|
||||
const clampedEventsCount = Math.max(0, Math.min(10, nextEventsCount));
|
||||
const nextEvents = useMemo(
|
||||
() =>
|
||||
[...events]
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||
.slice(0, clampedEventsCount),
|
||||
[events, clampedEventsCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editMode ? (
|
||||
<details className="calendarSourcePanel widgetNoDrag">
|
||||
<summary>Kalenderauswahl</summary>
|
||||
|
||||
<div className="calendarSourceForm">
|
||||
{sources.length === 0 ? (
|
||||
<p className="muted">
|
||||
Keine Kalenderquellen angelegt. Lege Kalender in den Benutzereinstellungen an.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sources.map((source) => (
|
||||
<label className="calendarSourceCheckbox" key={source.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSourceIds.includes(source.id)}
|
||||
onChange={() => onToggleSource(widgetId, source.id)}
|
||||
/>
|
||||
<span className="calendarSourceColorDot" style={{ background: source.color }} />
|
||||
<span>
|
||||
<strong>{source.name}</strong>
|
||||
<small>{source.type === "EXCHANGE_EWS" ? "Exchange" : "ICS"}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label className="fieldLabel">
|
||||
Termine unter dem Kalender
|
||||
<select
|
||||
className="select"
|
||||
value={nextEventsCount}
|
||||
onChange={(event) => onChangeEventsCount(widgetId, Number(event.target.value))}
|
||||
>
|
||||
<option value={0}>Ausblenden</option>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
||||
<option key={n} value={n}>{n} {n === 1 ? "Termin" : "Termine"}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<div className="calendarHeader widgetNoDrag">
|
||||
<button type="button" className="calendarNavButton" onClick={() => setCalendarMonth((c) => new Date(c.getFullYear(), c.getMonth() - 1, 1))}>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<button type="button" className="calendarMonthButton" onClick={() => setCalendarMonth(new Date())}>
|
||||
{formatMonthLabel(calendarMonth)}
|
||||
</button>
|
||||
|
||||
<button type="button" className="calendarNavButton" onClick={() => setCalendarMonth((c) => new Date(c.getFullYear(), c.getMonth() + 1, 1))}>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedSourceIds.length === 0 ? <p className="muted calendarStatus">Keine Kalenderquelle ausgewählt.</p> : null}
|
||||
|
||||
<div className="calendarWeekdays">
|
||||
{weekdayLabels.map((label) => (
|
||||
<div className="calendarWeekday" key={label}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="calendarGrid">
|
||||
{calendarDays.map((day) => (
|
||||
<div
|
||||
className={[
|
||||
"calendarDay",
|
||||
day.inCurrentMonth ? "" : "calendarDayMuted",
|
||||
day.isToday ? "calendarDayToday" : "",
|
||||
day.events.length > 0 ? "calendarDayWithEvents" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={day.key}
|
||||
>
|
||||
<span className="calendarDayNumber">{day.date.getDate()}</span>
|
||||
|
||||
{day.events.length > 0 ? <span className="calendarEventCount">{day.events.length}</span> : null}
|
||||
|
||||
{day.events.length > 0 ? (
|
||||
<div className="calendarTooltip">
|
||||
{day.events.slice(0, 5).map((event) => (
|
||||
<div className="calendarTooltipItem" key={event.id}>
|
||||
<span className="calendarTooltipTime">{formatEventTime(event.start)}</span>
|
||||
<span>{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{day.events.length > 5 ? <div className="calendarTooltipMore">Weitere Termine vorhanden</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="muted calendarStatus">{error}</p> : null}
|
||||
|
||||
{clampedEventsCount > 0 ? (
|
||||
<section className="nextEventsBlock">
|
||||
<h3>Nächste Termine</h3>
|
||||
|
||||
{nextEvents.length === 0 ? <p className="muted">Keine nächsten Termine.</p> : null}
|
||||
|
||||
<div className="eventList">
|
||||
{nextEvents.map((event) => (
|
||||
<article className="eventItem" key={event.id}>
|
||||
<div className="eventDate">{formatEventDate(event.start)}</div>
|
||||
<div className="eventTitle">{event.title}</div>
|
||||
{event.location ? <div className="eventLocation">{event.location}</div> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
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 = {
|
||||
note: NoteBoardItem | null;
|
||||
editMode: boolean;
|
||||
isEditing: boolean;
|
||||
noteError: string | null;
|
||||
onSave: (noteId: string, patch: { content: string }) => void;
|
||||
onInitialize: () => void;
|
||||
};
|
||||
|
||||
function getNoteTextarea(noteId: string): HTMLTextAreaElement | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return document.getElementById(`note-textarea-${noteId}`) as HTMLTextAreaElement | null;
|
||||
}
|
||||
|
||||
function normalizeMarkdownHref(href: string): string {
|
||||
const cleanHref = href.trim();
|
||||
|
||||
if (cleanHref.startsWith("/") || cleanHref.startsWith("#")) {
|
||||
return cleanHref;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(cleanHref);
|
||||
|
||||
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:" || parsedUrl.protocol === "mailto:") {
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
} catch {
|
||||
return "#";
|
||||
}
|
||||
|
||||
return "#";
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
const pattern = /(\*\*[^*\n]+?\*\*|\*[^*\n]+?\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)]+?\))/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
|
||||
if (token.startsWith("**") && token.endsWith("**")) {
|
||||
nodes.push(<strong key={`strong-${key}`}>{token.slice(2, -2)}</strong>);
|
||||
} else if (token.startsWith("*") && token.endsWith("*")) {
|
||||
nodes.push(<em key={`em-${key}`}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith("`") && token.endsWith("`")) {
|
||||
nodes.push(<code key={`code-${key}`}>{token.slice(1, -1)}</code>);
|
||||
} else {
|
||||
const linkMatch = token.match(/^\[([^\]]+?)\]\(([^)]+?)\)$/);
|
||||
|
||||
if (linkMatch) {
|
||||
nodes.push(
|
||||
<a key={`link-${key}`} href={normalizeMarkdownHref(linkMatch[2])} target="_blank" rel="noreferrer">
|
||||
{linkMatch[1]}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
nodes.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
key += 1;
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export default function NoteWidget({ note, editMode, isEditing, noteError, onSave, onInitialize }: NoteWidgetProps) {
|
||||
const [localContent, setLocalContent] = useState<string | null>(null);
|
||||
|
||||
const content = localContent ?? note?.content ?? "";
|
||||
|
||||
function updateContentFromToolbar(nextContent: string, nextSelectionStart: number, nextSelectionEnd = nextSelectionStart) {
|
||||
setLocalContent(nextContent);
|
||||
onSave(note!.id, { content: nextContent });
|
||||
|
||||
window.setTimeout(() => {
|
||||
const textarea = getNoteTextarea(note!.id);
|
||||
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextSelectionStart, nextSelectionEnd);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function wrapSelection(before: string, after: string, placeholder: string) {
|
||||
if (!note) return;
|
||||
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
const value = content;
|
||||
const start = textarea?.selectionStart ?? value.length;
|
||||
const end = textarea?.selectionEnd ?? value.length;
|
||||
const selectedText = value.slice(start, end);
|
||||
|
||||
const beforeSelection = value.slice(Math.max(0, start - before.length), start);
|
||||
const afterSelection = value.slice(end, end + after.length);
|
||||
|
||||
if (selectedText && beforeSelection === before && afterSelection === after) {
|
||||
const nextContent = `${value.slice(0, start - before.length)}${selectedText}${value.slice(end + after.length)}`;
|
||||
const nextStart = start - before.length;
|
||||
const nextEnd = nextStart + selectedText.length;
|
||||
|
||||
updateContentFromToolbar(nextContent, nextStart, nextEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedText.startsWith(before) && selectedText.endsWith(after) && selectedText.length > before.length + after.length) {
|
||||
const unwrappedText = selectedText.slice(before.length, selectedText.length - after.length);
|
||||
const nextContent = `${value.slice(0, start)}${unwrappedText}${value.slice(end)}`;
|
||||
|
||||
updateContentFromToolbar(nextContent, start, start + unwrappedText.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const insertedText = selectedText || placeholder;
|
||||
const replacement = `${before}${insertedText}${after}`;
|
||||
const nextContent = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||
const selectionStart = start + before.length;
|
||||
const selectionEnd = selectionStart + insertedText.length;
|
||||
|
||||
updateContentFromToolbar(nextContent, selectionStart, selectionEnd);
|
||||
}
|
||||
|
||||
function prefixLines(prefix: string, placeholder: string) {
|
||||
if (!note) return;
|
||||
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
const value = content;
|
||||
const start = textarea?.selectionStart ?? value.length;
|
||||
const end = textarea?.selectionEnd ?? value.length;
|
||||
const lineStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
||||
const lineEndCandidate = end === start ? end : end;
|
||||
const nextLineBreak = value.indexOf("\n", lineEndCandidate);
|
||||
const lineEnd = nextLineBreak === -1 || end === start ? lineEndCandidate : lineEndCandidate;
|
||||
const selectedBlock = value.slice(lineStart, lineEnd) || placeholder;
|
||||
const lines = selectedBlock.split("\n");
|
||||
|
||||
const isCheckboxMode = prefix === "- [ ] ";
|
||||
const isOrderedMode = prefix === "1. ";
|
||||
const isBulletMode = prefix === "- ";
|
||||
|
||||
const checkboxPattern = /^(\s*)[-*]\s+\[[ xX]\]\s+/;
|
||||
const orderedPattern = /^(\s*)\d+\.\s+/;
|
||||
const bulletPattern = /^(\s*)[-*]\s+(?!\[[ xX]\]\s+)/;
|
||||
|
||||
const alreadyFormatted = lines.every((line) => {
|
||||
if (!line.trim()) return true;
|
||||
if (isCheckboxMode) return checkboxPattern.test(line);
|
||||
if (isOrderedMode) return orderedPattern.test(line);
|
||||
if (isBulletMode) return bulletPattern.test(line);
|
||||
return false;
|
||||
});
|
||||
|
||||
const nextLines = lines.map((line, index) => {
|
||||
if (!line.trim()) return line;
|
||||
|
||||
if (alreadyFormatted) {
|
||||
if (isCheckboxMode) return line.replace(checkboxPattern, "$1");
|
||||
if (isOrderedMode) return line.replace(orderedPattern, "$1");
|
||||
if (isBulletMode) return line.replace(bulletPattern, "$1");
|
||||
return line;
|
||||
}
|
||||
|
||||
const cleanLine = line
|
||||
.replace(checkboxPattern, "$1")
|
||||
.replace(orderedPattern, "$1")
|
||||
.replace(bulletPattern, "$1");
|
||||
|
||||
if (isOrderedMode) {
|
||||
return cleanLine.replace(/^(\s*)/, `$1${index + 1}. `);
|
||||
}
|
||||
|
||||
return cleanLine.replace(/^(\s*)/, `$1${prefix}`);
|
||||
});
|
||||
|
||||
const prefixedBlock = nextLines.join("\n");
|
||||
const nextContent = `${value.slice(0, lineStart)}${prefixedBlock}${value.slice(lineEnd)}`;
|
||||
|
||||
updateContentFromToolbar(nextContent, lineStart, lineStart + prefixedBlock.length);
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
if (!note) return;
|
||||
|
||||
const textarea = getNoteTextarea(note.id);
|
||||
const value = content;
|
||||
const start = textarea?.selectionStart ?? value.length;
|
||||
const end = textarea?.selectionEnd ?? value.length;
|
||||
const selectedText = value.slice(start, end) || "Linktext";
|
||||
const replacement = `[${selectedText}](https://)`;
|
||||
const nextContent = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||
const urlStart = start + selectedText.length + 3;
|
||||
|
||||
updateContentFromToolbar(nextContent, urlStart, urlStart + 8);
|
||||
}
|
||||
|
||||
function toggleCheckbox(lineIndex: number) {
|
||||
if (!note) return;
|
||||
|
||||
const lines = content.split("\n");
|
||||
const line = lines[lineIndex] ?? "";
|
||||
const match = line.match(/^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/);
|
||||
|
||||
if (!match) return;
|
||||
|
||||
const isChecked = match[2].toLowerCase() === "x";
|
||||
lines[lineIndex] = `${match[1]}${isChecked ? " " : "x"}${match[3]}`;
|
||||
|
||||
const nextContent = lines.join("\n");
|
||||
setLocalContent(nextContent);
|
||||
onSave(note.id, { content: nextContent });
|
||||
}
|
||||
|
||||
function renderMarkdownPreview() {
|
||||
if (!content.trim()) {
|
||||
return <p className="muted noteEmptyText">Noch keine Notiz.</p>;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const blocks: ReactNode[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
if (!line.trim()) {
|
||||
blocks.push(<div className="noteMarkdownSpacer" key={`space-${index}`} />);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const className = `noteMarkdownHeading noteMarkdownHeading${level}`;
|
||||
|
||||
blocks.push(
|
||||
<div className={className} key={`heading-${index}`}>
|
||||
{renderInlineMarkdown(headingMatch[2])}
|
||||
</div>
|
||||
);
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskMatch = line.match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/);
|
||||
|
||||
if (taskMatch) {
|
||||
const tasks: Array<{ lineIndex: number; checked: boolean; text: string }> = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentMatch = lines[index].match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/);
|
||||
|
||||
if (!currentMatch) break;
|
||||
|
||||
tasks.push({
|
||||
lineIndex: index,
|
||||
checked: currentMatch[2].toLowerCase() === "x",
|
||||
text: currentMatch[3]
|
||||
});
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<div className="noteMarkdownChecklist" key={`tasks-${tasks[0]?.lineIndex ?? index}`}>
|
||||
{tasks.map((task) => (
|
||||
<label className="noteMarkdownTask" key={`task-${task.lineIndex}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.checked}
|
||||
onChange={() => toggleCheckbox(task.lineIndex)}
|
||||
/>
|
||||
<span>{renderInlineMarkdown(task.text)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedMatch = line.match(/^\s*[-*]\s+(?!\[[ xX]\]\s+)(.+)$/);
|
||||
|
||||
if (unorderedMatch) {
|
||||
const items: Array<{ lineIndex: number; text: string }> = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentMatch = lines[index].match(/^\s*[-*]\s+(?!\[[ xX]\]\s+)(.+)$/);
|
||||
|
||||
if (!currentMatch) break;
|
||||
|
||||
items.push({ lineIndex: index, text: currentMatch[1] });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<ul className="noteMarkdownList" key={`ul-${items[0]?.lineIndex ?? index}`}>
|
||||
{items.map((item) => (
|
||||
<li key={`ul-item-${item.lineIndex}`}>{renderInlineMarkdown(item.text)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
|
||||
if (orderedMatch) {
|
||||
const items: Array<{ lineIndex: number; text: string }> = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentMatch = lines[index].match(/^\s*\d+\.\s+(.+)$/);
|
||||
|
||||
if (!currentMatch) break;
|
||||
|
||||
items.push({ lineIndex: index, text: currentMatch[1] });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<ol className="noteMarkdownList" key={`ol-${items[0]?.lineIndex ?? index}`}>
|
||||
{items.map((item) => (
|
||||
<li key={`ol-item-${item.lineIndex}`}>{renderInlineMarkdown(item.text)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p className="noteMarkdownParagraph" key={`p-${index}`}>
|
||||
{renderInlineMarkdown(line)}
|
||||
</p>
|
||||
);
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return <div className="noteMarkdownPreview widgetNoDrag">{blocks}</div>;
|
||||
}
|
||||
|
||||
function renderToolbar() {
|
||||
if (!note) return null;
|
||||
|
||||
return (
|
||||
<div className="noteMarkdownToolbar widgetNoDrag" aria-label="Markdown-Werkzeuge">
|
||||
<button type="button" className="noteMarkdownButton" onMouseDown={(e) => e.preventDefault()} onClick={() => wrapSelection("**", "**", "fett")} title="Fett">
|
||||
B
|
||||
</button>
|
||||
<button type="button" className="noteMarkdownButton" onMouseDown={(e) => e.preventDefault()} onClick={() => wrapSelection("*", "*", "kursiv")} title="Kursiv">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button type="button" className="noteMarkdownButton noteMarkdownButtonWide" onMouseDown={(e) => e.preventDefault()} onClick={() => prefixLines("- ", "Eintrag")} title="Aufzählung">
|
||||
• Liste
|
||||
</button>
|
||||
<button type="button" className="noteMarkdownButton noteMarkdownButtonWide" onMouseDown={(e) => e.preventDefault()} onClick={() => prefixLines("1. ", "Eintrag")} title="Nummerierte Liste">
|
||||
1.
|
||||
</button>
|
||||
<button type="button" className="noteMarkdownButton noteMarkdownButtonWide" onMouseDown={(e) => e.preventDefault()} onClick={() => prefixLines("- [ ] ", "Aufgabe")} title="Checkbox">
|
||||
☐
|
||||
</button>
|
||||
<button type="button" className="noteMarkdownButton" onMouseDown={(e) => e.preventDefault()} onClick={() => wrapSelection("`", "`", "Code")} title="Inline-Code">
|
||||
{"</>"}
|
||||
</button>
|
||||
<button type="button" className="noteMarkdownButton noteMarkdownButtonWide" onMouseDown={(e) => e.preventDefault()} onClick={() => insertLink()} title="Link">
|
||||
Link
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return (
|
||||
<div className="singleNoteWidget widgetNoDrag">
|
||||
{noteError ? <p className="errorText">{noteError}</p> : null}
|
||||
<button type="button" className="button buttonSecondary" onClick={onInitialize}>
|
||||
Eintrag initialisieren
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noteIsEditing = editMode || isEditing;
|
||||
|
||||
if (!noteIsEditing) {
|
||||
return renderMarkdownPreview();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="singleNoteWidget noteMarkdownEditor widgetNoDrag">
|
||||
{noteError ? <p className="errorText">{noteError}</p> : null}
|
||||
{renderToolbar()}
|
||||
<textarea
|
||||
id={`note-textarea-${note.id}`}
|
||||
className="noteTextarea singleNoteTextarea"
|
||||
value={content}
|
||||
onChange={(event) => setLocalContent(event.target.value)}
|
||||
onBlur={(event) => {
|
||||
onSave(note.id, { content: event.target.value });
|
||||
setLocalContent(null);
|
||||
}}
|
||||
placeholder="Notiz schreiben... Markdown wird unterstützt."
|
||||
spellCheck={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user