From 4ee365cfc06ed3201b55fe3d2ce30ebbb5337490 Mon Sep 17 00:00:00 2001 From: Shivam Patel Date: Mon, 9 Feb 2026 17:51:06 -0500 Subject: [PATCH] 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 --- app/api/rss/feeds/route.ts | 9 +- app/api/rss/route.ts | 2 +- app/globals.css | 26 +++++ app/page.tsx | 13 +-- components/dashboard/GridShell.tsx | 173 ++++++++++++++++++++++++++--- components/widgets/GlobeCard.tsx | 4 +- components/widgets/NewsFeed.tsx | 8 +- components/widgets/UptimeCard.tsx | 15 ++- components/widgets/WeatherCard.tsx | 8 +- lib/db.ts | 18 +++ monitor.js | 9 +- package-lock.json | 84 +++++++++++++- package.json | 2 + 13 files changed, 324 insertions(+), 47 deletions(-) diff --git a/app/api/rss/feeds/route.ts b/app/api/rss/feeds/route.ts index 05893f9..c9a9528 100644 --- a/app/api/rss/feeds/route.ts +++ b/app/api/rss/feeds/route.ts @@ -4,6 +4,13 @@ import Parser from 'rss-parser'; export const dynamic = 'force-dynamic'; +function normalizeDate(item: { isoDate?: string; pubDate?: string }): string | null { + const raw = item.isoDate || item.pubDate; + if (!raw) return null; + const d = new Date(raw); + return isNaN(d.getTime()) ? null : d.toISOString(); +} + export async function GET() { const db = await getDb(); const feeds = await db.all(` @@ -56,7 +63,7 @@ export async function POST(req: NextRequest) { feedId, item.title, item.link, - item.pubDate || item.isoDate || null, + normalizeDate(item), item.creator || item.author || null, (item.contentSnippet || item.content || '').substring(0, 500) || null ); diff --git a/app/api/rss/route.ts b/app/api/rss/route.ts index dc2051c..017da9e 100644 --- a/app/api/rss/route.ts +++ b/app/api/rss/route.ts @@ -30,7 +30,7 @@ export async function GET(req: NextRequest) { } if (since) { const sinceMap: Record = { - '1h': '-1 hours', + '12h': '-12 hours', '24h': '-24 hours', '7d': '-7 days', '30d': '-30 days', diff --git a/app/globals.css b/app/globals.css index 5413518..dc6c8b6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,5 @@ +@import "react-grid-layout/css/styles.css"; +@import "react-resizable/css/styles.css"; @import "tailwindcss"; :root { @@ -43,3 +45,27 @@ body { .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: #525252; } + +/* react-grid-layout dark theme overrides */ +.react-grid-item > .react-resizable-handle::after { + border-right-color: #525252; + border-bottom-color: #525252; +} +.react-grid-item > .react-resizable-handle:hover::after { + border-right-color: #a1a1aa; + border-bottom-color: #a1a1aa; +} +.react-grid-item.react-grid-placeholder { + background: rgba(245, 158, 11, 0.15); + border: 1px dashed rgba(245, 158, 11, 0.4); + border-radius: 0.75rem; +} +.react-grid-item { + transition: all 200ms ease; +} +.widget-drag-handle { + cursor: grab; +} +.widget-drag-handle:active { + cursor: grabbing; +} diff --git a/app/page.tsx b/app/page.tsx index 1112dba..567abe3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,15 +7,10 @@ import { NewsFeed } from "@/components/widgets/NewsFeed"; export default function Home() { return ( - {/* Row 1 */} - - - - {/* Row 1 & 2 (Globe spans 2 rows) */} - - - {/* Row 2-4: NewsFeed spans 4 cols, 3 rows */} - +
+
+
+
); } diff --git a/components/dashboard/GridShell.tsx b/components/dashboard/GridShell.tsx index 72301c6..79ffb78 100644 --- a/components/dashboard/GridShell.tsx +++ b/components/dashboard/GridShell.tsx @@ -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({ + 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(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 ( -
-
-
-
-

Mission Control

+ +
+
+
+
+

Mission Control

+
+
+ +
+
+ SYSTEM ONLINE +
+
+
+
}> + {mounted && ready && ( + + {childArray.map((child) => { + if (!React.isValidElement(child)) return null; + const key = child.key as string; + return ( +
+ {child} +
+ ); + })} +
+ )}
-
- {/* Future header controls */} -
- SYSTEM ONLINE -
-
- -
- {children} -
+
- + ); } diff --git a/components/widgets/GlobeCard.tsx b/components/widgets/GlobeCard.tsx index 3eefcb4..4767359 100644 --- a/components/widgets/GlobeCard.tsx +++ b/components/widgets/GlobeCard.tsx @@ -155,8 +155,8 @@ export function GlobeCard() { }, []); return ( -
-
+
+
Visitor Map diff --git a/components/widgets/NewsFeed.tsx b/components/widgets/NewsFeed.tsx index d243a53..a0dc6cf 100644 --- a/components/widgets/NewsFeed.tsx +++ b/components/widgets/NewsFeed.tsx @@ -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 ( -
+
{/* Header */} -
+
Feed diff --git a/components/widgets/UptimeCard.tsx b/components/widgets/UptimeCard.tsx index f52d2d4..bd69f45 100644 --- a/components/widgets/UptimeCard.tsx +++ b/components/widgets/UptimeCard.tsx @@ -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 ( <> -
+
{/* Header */} -
+
Uptime Monitor @@ -608,7 +615,7 @@ export function UptimeCard() {