b06a2d1066
Begruessung nach Tageszeit mit Datum, Befehlsleiste oben rechts statt schwebendem Knopf, einheitliche Kachel-Kopfzeilen mit Symbol, Kalender mit Pfeilknoepfen, runden Tagen und Terminpunkten, Favoriten als 36-px-Zeilen bzw. App-Starter-Kacheln, Notiz-Kaestchen im Akzent, leerer Zustand mit drei Vorschlaegen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
227 lines
7.8 KiB
TypeScript
227 lines
7.8 KiB
TypeScript
'use client';
|
|
|
|
import { WidgetIcon } from './widget-icon';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useTheme } from 'next-themes';
|
|
import MDEditor, { commands } from '@uiw/react-md-editor';
|
|
import rehypeSanitize from 'rehype-sanitize';
|
|
import type { WidgetProps } from '../widget-registry';
|
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
|
import { NoteCheckbox, toggleTaskLine } from './note-task-list';
|
|
|
|
const DEBOUNCE_MS = 1500;
|
|
|
|
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
|
|
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
|
|
// durch NoteCheckbox (siehe note-task-list.tsx). Typ direkt von MDEditor
|
|
// abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview`
|
|
// importiert (das ist keine direkte Dependency von apps/web und daher unter
|
|
// pnpms strikter Isolation nicht von hier aus aufloesbar).
|
|
type PreviewOptions = NonNullable<
|
|
React.ComponentProps<typeof MDEditor>['previewOptions']
|
|
>;
|
|
|
|
const NOTE_COMMANDS = [
|
|
commands.bold,
|
|
commands.italic,
|
|
commands.strikethrough,
|
|
commands.divider,
|
|
commands.unorderedListCommand,
|
|
commands.checkedListCommand,
|
|
commands.divider,
|
|
commands.link,
|
|
commands.code,
|
|
];
|
|
|
|
export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|
const t = useTranslations('widgets');
|
|
const { resolvedTheme } = useTheme();
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// quick-260916-j4f: folgt dem Tessera-Schalter statt der
|
|
// Betriebssystem-Einstellung, Muster changelog-view.tsx.
|
|
const colorMode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
|
|
|
|
const [title, setTitle] = useState<string>(
|
|
(config.title as string) || t('note.defaultTitle'),
|
|
);
|
|
const [content, setContent] = useState<string>(
|
|
(config.content as string) || '',
|
|
);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [saveError, setSaveError] = useState(false);
|
|
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
const abortRef = useRef<AbortController | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearTimeout(timerRef.current);
|
|
abortRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
const save = useCallback(
|
|
async (newContent: string, newTitle: string) => {
|
|
abortRef.current?.abort();
|
|
abortRef.current = new AbortController();
|
|
try {
|
|
await updateWidgetConfig(instanceId, { content: newContent, title: newTitle }, abortRef.current.signal);
|
|
setSaveError(false);
|
|
} catch (err: unknown) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
setSaveError(true);
|
|
}
|
|
},
|
|
[instanceId],
|
|
);
|
|
|
|
const scheduleSave = useCallback(
|
|
(newContent: string, newTitle: string) => {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => save(newContent, newTitle), DEBOUNCE_MS);
|
|
},
|
|
[save],
|
|
);
|
|
|
|
const handleContentChange = useCallback(
|
|
(val?: string) => {
|
|
const newContent = val ?? '';
|
|
setContent(newContent);
|
|
scheduleSave(newContent, title);
|
|
},
|
|
[scheduleSave, title],
|
|
);
|
|
|
|
const handleTitleChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newTitle = e.target.value;
|
|
setTitle(newTitle);
|
|
scheduleSave(content, newTitle);
|
|
},
|
|
[scheduleSave, content],
|
|
);
|
|
|
|
// Vorschau-Behaelter: die Index-Ermittlung (n-tes Kaestchen im Behaelter ==
|
|
// n-te Aufgabenzeile) bleibt Wort fuer Wort wie zuvor, findet den Behaelter
|
|
// jetzt aber ueber ein ref statt ueber event.currentTarget, weil der Klick
|
|
// ab jetzt am Kaestchen selbst entsteht (quick-260921-jt4, D-01: das
|
|
// Kaestchen bedient sich selbst statt vom Behaelter bedient zu werden).
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
|
|
// quick-260916-iex: Abhaken eines Aufgaben-Kaestchens kippt genau diese
|
|
// Zeile und speichert sofort (kein Entprellen — eine Tastatur-/Maus-
|
|
// betaetigung ist eine abgeschlossene Handlung). Ein noch laufender
|
|
// Entprell-Timer aus dem Tippen wird verworfen, damit er den ungekippten
|
|
// Text nicht nachschiebt.
|
|
const handleCheckboxToggle = useCallback(
|
|
(element: HTMLInputElement) => {
|
|
if (isEditing) return;
|
|
const container = previewRef.current;
|
|
if (!container) return;
|
|
const boxes = Array.from(
|
|
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
|
|
);
|
|
const index = boxes.indexOf(element);
|
|
const next = toggleTaskLine(content, index);
|
|
if (next === content) return;
|
|
setContent(next);
|
|
clearTimeout(timerRef.current);
|
|
void save(next, title);
|
|
},
|
|
[isEditing, content, title, save],
|
|
);
|
|
|
|
// Der Rueckruf, den `previewOptions.components.input` letztlich aufruft,
|
|
// haengt via ref an der jeweils aktuellen `handleCheckboxToggle` — das
|
|
// haelt `previewOptions` selbst identitaetsstabil (useMemo mit leerer
|
|
// Abhaengigkeitsliste), waehrend der Rueckruf trotzdem immer den frischen
|
|
// Zustand sieht. Ein pro Tastendruck neu erzeugtes Optionsobjekt liesse
|
|
// react-markdown bei jedem Zeichen neu abgleichen.
|
|
const handleCheckboxToggleRef = useRef(handleCheckboxToggle);
|
|
handleCheckboxToggleRef.current = handleCheckboxToggle;
|
|
|
|
const previewOptions = useMemo<PreviewOptions>(
|
|
() => ({
|
|
rehypePlugins: [[rehypeSanitize]],
|
|
components: {
|
|
input: (props: { checked?: boolean }) => (
|
|
<NoteCheckbox
|
|
checked={props.checked}
|
|
onToggle={(element) => handleCheckboxToggleRef.current(element)}
|
|
/>
|
|
),
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
|
{/* Header: title + pencil toggle */}
|
|
<div className="widget-head relative">
|
|
<WidgetIcon type="note" />
|
|
<input
|
|
type="text"
|
|
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
|
value={title}
|
|
onChange={handleTitleChange}
|
|
placeholder={t('note.defaultTitle')}
|
|
/>
|
|
{saveError && (
|
|
<span
|
|
className="h-2 w-2 shrink-0 rounded-full bg-destructive"
|
|
title={t('note.autosaveError')}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsEditing((v) => !v)}
|
|
className={`widget-action flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-muted ${isEditing ? 'widget-action-pinned bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
|
title={isEditing ? t('note.viewMode') : t('note.editMode')}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content: preview or editor */}
|
|
<div
|
|
className="widget-note-body flex-1 overflow-auto"
|
|
data-testid="note-preview"
|
|
ref={previewRef}
|
|
>
|
|
<MDEditor
|
|
data-testid="md-editor"
|
|
value={content}
|
|
onChange={isEditing ? handleContentChange : undefined}
|
|
commands={NOTE_COMMANDS}
|
|
preview={isEditing ? 'edit' : 'preview'}
|
|
hideToolbar={!isEditing}
|
|
height="100%"
|
|
visibleDragbar={false}
|
|
previewOptions={previewOptions}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|