Fix RSS time filters and add draggable grid layout
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>
This commit is contained in:
@@ -1,29 +1,168 @@
|
||||
import React from 'react';
|
||||
'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 (
|
||||
<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>
|
||||
<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 className="flex gap-2">
|
||||
{/* Future header controls */}
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-4 auto-rows-[200px]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user