Normalize pub_date to ISO 8601 on insert so SQLite datetime comparisons work correctly. Migrate existing RFC 2822 dates. Change 1h filter to 12h. Add react-grid-layout with persistent drag/resize and reset button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import { ResponsiveGridLayout, useContainerWidth, verticalCompactor, type ResponsiveLayouts, type Layout } from 'react-grid-layout';
|
|
import { RotateCcw } from 'lucide-react';
|
|
|
|
const STORAGE_KEY = 'dashboard-layouts';
|
|
|
|
const BREAKPOINTS = { lg: 1024, md: 768, sm: 0 };
|
|
const COLS = { lg: 6, md: 4, sm: 1 };
|
|
const ROW_HEIGHT = 200;
|
|
const MARGIN: [number, number] = [16, 16];
|
|
|
|
const DEFAULT_LAYOUTS: ResponsiveLayouts = {
|
|
lg: [
|
|
{ i: 'uptime', x: 0, y: 0, w: 2, h: 1, minW: 2, minH: 1 },
|
|
{ i: 'weather', x: 2, y: 0, w: 2, h: 1, minW: 2, minH: 1 },
|
|
{ i: 'globe', x: 4, y: 0, w: 2, h: 2, minW: 2, minH: 2 },
|
|
{ i: 'newsfeed', x: 0, y: 1, w: 4, h: 3, minW: 2, minH: 2 },
|
|
],
|
|
md: [
|
|
{ i: 'uptime', x: 0, y: 0, w: 2, h: 1, minW: 2, minH: 1 },
|
|
{ i: 'weather', x: 2, y: 0, w: 2, h: 1, minW: 2, minH: 1 },
|
|
{ i: 'globe', x: 0, y: 1, w: 2, h: 2, minW: 2, minH: 2 },
|
|
{ i: 'newsfeed', x: 2, y: 1, w: 2, h: 3, minW: 2, minH: 2 },
|
|
],
|
|
sm: [
|
|
{ i: 'uptime', x: 0, y: 0, w: 1, h: 1, minW: 1, minH: 1 },
|
|
{ i: 'weather', x: 0, y: 1, w: 1, h: 1, minW: 1, minH: 1 },
|
|
{ i: 'globe', x: 0, y: 2, w: 1, h: 2, minW: 1, minH: 2 },
|
|
{ i: 'newsfeed', x: 0, y: 4, w: 1, h: 3, minW: 1, minH: 2 },
|
|
],
|
|
};
|
|
|
|
const DRAG_CONFIG = { handle: '.widget-drag-handle' };
|
|
|
|
// --- Layout Context ---
|
|
|
|
interface LayoutContextValue {
|
|
updateWidgetSize: (key: string, w: number, h: number) => void;
|
|
}
|
|
|
|
const LayoutContext = createContext<LayoutContextValue>({
|
|
updateWidgetSize: () => {},
|
|
});
|
|
|
|
export function useLayoutContext() {
|
|
return useContext(LayoutContext);
|
|
}
|
|
|
|
// --- GridShell ---
|
|
|
|
interface GridShellProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function loadSavedLayouts(): ResponsiveLayouts | null {
|
|
if (typeof window === 'undefined') return null;
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
function persistLayouts(layouts: ResponsiveLayouts) {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts));
|
|
} catch {}
|
|
}
|
|
|
|
export function GridShell({ children }: GridShellProps) {
|
|
const [layouts, setLayouts] = useState<ResponsiveLayouts>(DEFAULT_LAYOUTS);
|
|
const [ready, setReady] = useState(false);
|
|
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1280 });
|
|
|
|
useEffect(() => {
|
|
const saved = loadSavedLayouts();
|
|
if (saved) setLayouts(saved);
|
|
setReady(true);
|
|
}, []);
|
|
|
|
const handleLayoutChange = useCallback((_layout: Layout, allLayouts: ResponsiveLayouts) => {
|
|
setLayouts(allLayouts);
|
|
persistLayouts(allLayouts);
|
|
}, []);
|
|
|
|
const handleReset = useCallback(() => {
|
|
setLayouts(DEFAULT_LAYOUTS);
|
|
persistLayouts(DEFAULT_LAYOUTS);
|
|
}, []);
|
|
|
|
const updateWidgetSize = useCallback((key: string, w: number, h: number) => {
|
|
setLayouts((prev) => {
|
|
const next: ResponsiveLayouts = {};
|
|
for (const bp of Object.keys(prev)) {
|
|
const bpLayout = prev[bp];
|
|
if (!bpLayout) continue;
|
|
next[bp] = bpLayout.map((item) =>
|
|
item.i === key ? { ...item, w, h } : item
|
|
);
|
|
}
|
|
persistLayouts(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const contextValue = useMemo(
|
|
() => ({ updateWidgetSize }),
|
|
[updateWidgetSize]
|
|
);
|
|
|
|
const childArray = React.Children.toArray(children);
|
|
|
|
return (
|
|
<LayoutContext.Provider value={contextValue}>
|
|
<div className="min-h-screen bg-neutral-950 text-neutral-200 p-4 md:p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<header className="mb-8 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight text-white mb-1">Mission Control</h1>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={handleReset}
|
|
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors px-2 py-1 rounded-lg hover:bg-neutral-800"
|
|
title="Reset layout"
|
|
>
|
|
<RotateCcw size={13} />
|
|
Reset layout
|
|
</button>
|
|
<div className="flex gap-2 items-center">
|
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span className="text-xs font-mono text-emerald-500">SYSTEM ONLINE</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div ref={containerRef as React.RefObject<HTMLDivElement>}>
|
|
{mounted && ready && (
|
|
<ResponsiveGridLayout
|
|
width={width}
|
|
layouts={layouts}
|
|
breakpoints={BREAKPOINTS}
|
|
cols={COLS}
|
|
rowHeight={ROW_HEIGHT}
|
|
margin={MARGIN}
|
|
onLayoutChange={handleLayoutChange}
|
|
dragConfig={DRAG_CONFIG}
|
|
compactor={verticalCompactor}
|
|
>
|
|
{childArray.map((child) => {
|
|
if (!React.isValidElement(child)) return null;
|
|
const key = child.key as string;
|
|
return (
|
|
<div key={key} className="h-full">
|
|
{child}
|
|
</div>
|
|
);
|
|
})}
|
|
</ResponsiveGridLayout>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</LayoutContext.Provider>
|
|
);
|
|
}
|