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";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
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 { 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 CalculatorWidget from "@/components/CalculatorWidget";
|
import CalculatorWidget from "@/components/CalculatorWidget";
|
||||||
|
import CalendarWidget from "@/components/CalendarWidget";
|
||||||
import ClockWidget from "@/components/ClockWidget";
|
import ClockWidget from "@/components/ClockWidget";
|
||||||
import FavoritesWidget from "@/components/FavoritesWidget";
|
|
||||||
import DomainCheckWidget from "@/components/DomainCheckWidget";
|
import DomainCheckWidget from "@/components/DomainCheckWidget";
|
||||||
|
import FavoritesWidget from "@/components/FavoritesWidget";
|
||||||
|
import NoteWidget from "@/components/NoteWidget";
|
||||||
|
|
||||||
const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), {
|
const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -98,14 +100,6 @@ type CalendarWidgetCalendarConfig = {
|
|||||||
nextEventsCount: number;
|
nextEventsCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CalendarDay = {
|
|
||||||
key: string;
|
|
||||||
date: Date;
|
|
||||||
inCurrentMonth: boolean;
|
|
||||||
isToday: boolean;
|
|
||||||
events: CalendarEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type NoteBoardItem = {
|
type NoteBoardItem = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: 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 {
|
function getUserInitials(user: User): string {
|
||||||
const source = user.displayName?.trim() || user.email.trim();
|
const source = user.displayName?.trim() || user.email.trim();
|
||||||
|
|
||||||
@@ -227,37 +187,6 @@ function getUserInitials(user: User): string {
|
|||||||
return source.slice(0, 2).toUpperCase();
|
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 {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
@@ -330,8 +259,6 @@ export default function DashboardPage() {
|
|||||||
const [calendarSources, setCalendarSources] = useState<CalendarSource[]>([]);
|
const [calendarSources, setCalendarSources] = useState<CalendarSource[]>([]);
|
||||||
const [calendarSelectionsByWidget, setCalendarSelectionsByWidget] = useState<Record<string, string[]>>({});
|
const [calendarSelectionsByWidget, setCalendarSelectionsByWidget] = useState<Record<string, string[]>>({});
|
||||||
const [calendarNextEventsCountByWidget, setCalendarNextEventsCountByWidget] = useState<Record<string, number>>({});
|
const [calendarNextEventsCountByWidget, setCalendarNextEventsCountByWidget] = useState<Record<string, number>>({});
|
||||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
|
||||||
|
|
||||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||||
const [openWidgetMenuId, setOpenWidgetMenuId] = 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 dashboardSubtitle = settings?.dashboardSubtitle?.trim() || user?.email || "";
|
||||||
const logoUrl = settings?.logoUrl?.trim() || "/logo.svg";
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
try {
|
try {
|
||||||
@@ -956,18 +870,6 @@ export default function DashboardPage() {
|
|||||||
setProfileMenuOpen(false);
|
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) {
|
function renderNoteEditButton(widget: Widget) {
|
||||||
if (widget.type !== "note" && widget.type !== "noteboard") {
|
if (widget.type !== "note" && widget.type !== "noteboard") {
|
||||||
return null;
|
return null;
|
||||||
@@ -1195,548 +1097,6 @@ export default function DashboardPage() {
|
|||||||
return notes.find((note) => note.id === widget.id) ?? null;
|
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) {
|
async function reloadCalendarWidget(widgetId: string) {
|
||||||
const [configResponse, eventsResponse] = await Promise.all([
|
const [configResponse, eventsResponse] = await Promise.all([
|
||||||
fetch(`/api/calendar/source?widgetId=${encodeURIComponent(widgetId)}`, {
|
fetch(`/api/calendar/source?widgetId=${encodeURIComponent(widgetId)}`, {
|
||||||
@@ -1837,65 +1197,6 @@ export default function DashboardPage() {
|
|||||||
void saveCalendarWidgetSources(widgetId, selectedSourceIds, nextEventsCount);
|
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() {
|
function renderSearchWidget() {
|
||||||
return (
|
return (
|
||||||
<form className="searchWidgetForm widgetNoDrag" onSubmit={handleSearch}>
|
<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) {
|
function renderWidgetContent(widget: Widget) {
|
||||||
if (widget.type === "favorites") {
|
if (widget.type === "favorites") {
|
||||||
return (
|
return (
|
||||||
@@ -2030,7 +1236,18 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (widget.type === "note" || widget.type === "noteboard") {
|
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") {
|
if (widget.type === "search") {
|
||||||
@@ -2046,7 +1263,19 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (widget.type === "calendar") {
|
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") {
|
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