fix(dashboard): noCompactor + note edit/preview toggle + widget border
- dashboard-grid: add noCompactor to prevent auto-compaction on drag - note-widget: edit/preview toggle button (pencil icon), isEditing state, hideToolbar in preview mode - widget-wrapper: border-primary/20 accent border - dashboard-store: console.error on widget add/remove/layout-save failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Responsive } from 'react-grid-layout';
|
import { Responsive, noCompactor } from 'react-grid-layout';
|
||||||
import type { ResponsiveLayouts } from 'react-grid-layout';
|
import type { ResponsiveLayouts } from 'react-grid-layout';
|
||||||
import 'react-grid-layout/css/styles.css';
|
import 'react-grid-layout/css/styles.css';
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
@@ -97,6 +97,7 @@ export function DashboardGrid({
|
|||||||
resizeConfig={{
|
resizeConfig={{
|
||||||
enabled: isEditMode,
|
enabled: isEditMode,
|
||||||
}}
|
}}
|
||||||
|
compactor={noCompactor}
|
||||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||||
>
|
>
|
||||||
{widgets.map((widget) => {
|
{widgets.map((widget) => {
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import { updateWidgetConfig } from '@/lib/dashboard-api';
|
|||||||
|
|
||||||
const DEBOUNCE_MS = 1500;
|
const DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact toolbar commands (D-16).
|
|
||||||
* Bold, Italic, Strikethrough, divider, Unordered List, Checkbox, divider, Link, Code
|
|
||||||
*/
|
|
||||||
const NOTE_COMMANDS = [
|
const NOTE_COMMANDS = [
|
||||||
commands.bold,
|
commands.bold,
|
||||||
commands.italic,
|
commands.italic,
|
||||||
@@ -25,12 +21,6 @@ const NOTE_COMMANDS = [
|
|||||||
commands.code,
|
commands.code,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Notes widget (DASH-06, D-16/D-17/D-18).
|
|
||||||
* Markdown editor with compact toolbar and debounced autosave.
|
|
||||||
* Uses AbortController to cancel in-flight requests before issuing new ones (Pitfall 7).
|
|
||||||
* rehype-sanitize is enabled for XSS prevention (T-05-05).
|
|
||||||
*/
|
|
||||||
export function NoteWidget({ instanceId, config }: WidgetProps) {
|
export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const [title, setTitle] = useState<string>(
|
const [title, setTitle] = useState<string>(
|
||||||
@@ -39,12 +29,12 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
const [content, setContent] = useState<string>(
|
const [content, setContent] = useState<string>(
|
||||||
(config.content as string) || '',
|
(config.content as string) || '',
|
||||||
);
|
);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [saveError, setSaveError] = useState(false);
|
const [saveError, setSaveError] = useState(false);
|
||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const abortRef = useRef<AbortController | undefined>(undefined);
|
const abortRef = useRef<AbortController | undefined>(undefined);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
@@ -54,21 +44,13 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
|
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
async (newContent: string, newTitle: string) => {
|
async (newContent: string, newTitle: string) => {
|
||||||
// Abort any in-flight request (Pitfall 7)
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = new AbortController();
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateWidgetConfig(instanceId, {
|
await updateWidgetConfig(instanceId, { content: newContent, title: newTitle }, abortRef.current.signal);
|
||||||
content: newContent,
|
|
||||||
title: newTitle,
|
|
||||||
}, abortRef.current.signal);
|
|
||||||
setSaveError(false);
|
setSaveError(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// AbortError is expected — swallow it
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveError(true);
|
setSaveError(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -78,9 +60,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
const scheduleSave = useCallback(
|
const scheduleSave = useCallback(
|
||||||
(newContent: string, newTitle: string) => {
|
(newContent: string, newTitle: string) => {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => save(newContent, newTitle), DEBOUNCE_MS);
|
||||||
save(newContent, newTitle);
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
},
|
},
|
||||||
[save],
|
[save],
|
||||||
);
|
);
|
||||||
@@ -105,32 +85,52 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
|
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
|
||||||
{/* Editable title (D-17, Body 14px weight 600) */}
|
{/* Header: title + pencil toggle */}
|
||||||
<div className="relative flex items-center border-b border-border px-3 py-1.5">
|
<div className="relative flex items-center border-b border-border px-3 py-1.5 gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={handleTitleChange}
|
onChange={handleTitleChange}
|
||||||
placeholder={t('note.defaultTitle')}
|
placeholder={t('note.defaultTitle')}
|
||||||
/>
|
/>
|
||||||
{/* Autosave error indicator (small red dot top-right) */}
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<span
|
<span
|
||||||
className="absolute right-2 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-destructive"
|
className="h-2 w-2 shrink-0 rounded-full bg-destructive"
|
||||||
title={t('note.autosaveError')}
|
title={t('note.autosaveError')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing((v) => !v)}
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||||
|
title={isEditing ? t('note.viewMode') : t('note.editMode')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Markdown editor (D-16, D-18) */}
|
{/* Content: preview or editor */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<MDEditor
|
<MDEditor
|
||||||
data-testid="md-editor"
|
data-testid="md-editor"
|
||||||
value={content}
|
value={content}
|
||||||
onChange={handleContentChange}
|
onChange={isEditing ? handleContentChange : undefined}
|
||||||
commands={NOTE_COMMANDS}
|
commands={NOTE_COMMANDS}
|
||||||
preview="edit"
|
preview={isEditing ? 'edit' : 'preview'}
|
||||||
|
hideToolbar={!isEditing}
|
||||||
height="100%"
|
height="100%"
|
||||||
visibleDragbar={false}
|
visibleDragbar={false}
|
||||||
previewOptions={{
|
previewOptions={{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm"
|
className="relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm"
|
||||||
data-widget-id={widget.id}
|
data-widget-id={widget.id}
|
||||||
role="article"
|
role="article"
|
||||||
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
isDirty: true,
|
isDirty: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
set({ error: 'Failed to add widget' });
|
console.error('Failed to add widget:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -100,8 +100,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
isDirty: true,
|
isDirty: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
set({ error: 'Failed to remove widget' });
|
console.error('Failed to remove widget:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -129,8 +129,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await api.saveLayout(get().layouts);
|
await api.saveLayout(get().layouts);
|
||||||
set({ isDirty: false });
|
set({ isDirty: false });
|
||||||
} catch {
|
} catch (err) {
|
||||||
set({ error: 'Failed to save layout' });
|
console.error('Failed to save layout:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user