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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@ export function GlobeCard() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||
<div className="absolute top-6 left-6 z-10 pointer-events-none">
|
||||
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||
<div className="widget-drag-handle absolute top-6 left-6 right-6 z-10">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Globe size={18} />
|
||||
<span className="text-sm font-medium">Visitor Map</span>
|
||||
|
||||
@@ -150,10 +150,10 @@ function FeedFilterPills({
|
||||
);
|
||||
}
|
||||
|
||||
type TimeFilter = '1h' | '24h' | '7d' | '30d';
|
||||
type TimeFilter = '12h' | '24h' | '7d' | '30d';
|
||||
|
||||
const TIME_FILTER_OPTIONS: { value: TimeFilter; label: string }[] = [
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '12h', label: '12h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
{ value: '30d', label: '30d' },
|
||||
@@ -479,9 +479,9 @@ export function NewsFeed() {
|
||||
const hasFeeds = feeds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-4 lg:col-span-4 row-span-3 bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-colors overflow-hidden">
|
||||
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-colors overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 shrink-0 mb-3">
|
||||
<div className="widget-drag-handle flex items-center justify-between gap-3 shrink-0 mb-3">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Rss size={18} />
|
||||
<span className="text-sm font-medium">Feed</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink, ChevronDown, ChevronUp, Pencil, Check } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { format, subHours, subDays, subMonths } from 'date-fns';
|
||||
import { useLayoutContext } from '@/components/dashboard/GridShell';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -592,14 +593,20 @@ export function UptimeCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const rowSpan = expanded ? 'row-span-2' : 'row-span-1';
|
||||
const { updateWidgetSize } = useLayoutContext();
|
||||
const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
updateWidgetSize('uptime', 2, next ? 2 : 1);
|
||||
}, [expanded, updateWidgetSize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`col-span-1 md:col-span-2 lg:col-span-2 ${rowSpan} bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-all duration-300 overflow-hidden`}>
|
||||
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-all duration-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center shrink-0 mb-3">
|
||||
<div className="widget-drag-handle flex justify-between items-center shrink-0 mb-3">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Activity size={18} />
|
||||
<span className="text-sm font-medium">Uptime Monitor</span>
|
||||
@@ -608,7 +615,7 @@ export function UptimeCard() {
|
||||
<div className="flex items-center gap-2">
|
||||
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={handleToggleExpand}
|
||||
className="text-neutral-500 hover:text-neutral-300 transition-colors p-1 rounded-lg hover:bg-neutral-800"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function WeatherCard() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 relative overflow-hidden animate-pulse">
|
||||
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl p-6 relative overflow-hidden animate-pulse">
|
||||
<div className="h-4 w-24 bg-neutral-800 rounded mb-4" />
|
||||
<div className="h-10 w-16 bg-neutral-800 rounded" />
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ export function WeatherCard() {
|
||||
if (!weather?.current) {
|
||||
// ... error state
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex items-center justify-center text-red-500">
|
||||
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex items-center justify-center text-red-500">
|
||||
Failed to load weather
|
||||
</div>
|
||||
);
|
||||
@@ -79,8 +79,8 @@ export function WeatherCard() {
|
||||
const today = weather.daily;
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl p-6 relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||
<div className="widget-drag-handle flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Cloud size={18} />
|
||||
<span className="text-sm font-medium">Local Weather</span>
|
||||
|
||||
Reference in New Issue
Block a user