diff --git a/federated-dashboard b/federated-dashboard new file mode 160000 index 0000000..1a88bb7 --- /dev/null +++ b/federated-dashboard @@ -0,0 +1 @@ +Subproject commit 1a88bb7ed93ae467683d83d9e2335477d271d29a diff --git a/src/App.tsx b/src/App.tsx index 1be0c5b..8e7c448 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ -import React from "react"; import FederatedDashboard from "./FederatedDashboard"; export default function App() { return ; -} \ No newline at end of file +} diff --git a/src/FederatedDashboard.tsx b/src/FederatedDashboard.tsx index 7b5e701..5a3ed81 100644 --- a/src/FederatedDashboard.tsx +++ b/src/FederatedDashboard.tsx @@ -1,9 +1,11 @@ -// Federated Dashboard — React + Tailwind + Font Awesome 6 Free (Canvas Preview JS) -// Adds custom drag preview that follows the cursor with a blue (#0EA5E9 @ 50%) shadow. -// Features: theme toggle, favorites, search (headings hidden while typing), drag & drop reordering, -// custom floating preview, status pill, 4-col grid, centered Federated logo. +// Final Federated Dashboard — React + Tailwind + Font Awesome 6 Free (Type-safe) +// Features: theme toggle, favorites, debounced search, drag & drop reordering within sections, +// custom floating drag preview with blue (#0EA5E9 @ 50%) shadow, status pill, 4-col grid, +// centered Federated logo, and a top-left "Documentation" link. + +import { useMemo, useState, useCallback, useEffect, memo } from "react"; +import type React from "react"; -import React, { useMemo, useState, useCallback, useEffect } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons"; import { @@ -13,6 +15,7 @@ import { faMagnifyingGlass, faStar as faStarSolid, faXmark, + faComments, // icon for Documentation button } from "@fortawesome/free-solid-svg-icons"; // ------------------------------------ @@ -20,7 +23,7 @@ import { // ------------------------------------ const BRAND_TOKENS = { primary: "#0F172A", // dark theme background - accent: "#D67147", // favorites + focus ring + section label color + accent: "#D67147", // favorites + focus ring + section label color surface: "#1E293B", }; const LIGHT_TOKENS = { @@ -29,9 +32,38 @@ const LIGHT_TOKENS = { }; // ------------------------------------ -// Data +// Types +// ------------------------------------ +export type Theme = "light" | "dark"; +export type DragList = "fav" | "other"; + +export interface App { + id: string; + name: string; + category: string; // e.g. "Email, Files, Documents" + panelManaged: boolean; + logoUrl?: string; // local or remote logo + brandUrl?: string; // optional brand page +} + +interface DragState { id: string; from: DragList } +interface DragMeta { offsetX: number; offsetY: number; width: number; height: number; app: App } +interface DragPos { x: number; y: number } + +interface DndProps { + enabled: boolean; + list: DragList; + onDragStart: (id: string, list: DragList) => (e: React.DragEvent) => void; + onDragOver: (_overId: string, list: DragList) => (e: React.DragEvent) => void; + onDrop: (overId: string, list: DragList) => (e: React.DragEvent) => void; + onDrag: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; + draggingId?: string | null; +} + +// ------------------------------------ +// Data (local assets in public/assets) // ------------------------------------ -// Local asset files for app logos (place in public/assets/) const ASSETS = { panel_black: "/assets/panelblack.png", panel_white: "/assets/panelwhite.png", @@ -56,7 +88,7 @@ const ASSETS = { federated_logo_dark: "/assets/federated.png", }; -const APP_CATALOG = [ +const APP_CATALOG: App[] = [ { id: "nextcloud", name: "Nextcloud", category: "Email, Files, Documents", panelManaged: true, logoUrl: ASSETS.nextcloud }, { id: "jitsi", name: "Jitsi (Meet)", category: "Video Chat", panelManaged: true, logoUrl: ASSETS.jitsi }, { id: "element", name: "Element", category: "Team Chat", panelManaged: true, logoUrl: ASSETS.element }, @@ -76,10 +108,10 @@ const APP_CATALOG = [ ]; // ------------------------------------ -// Hooks +// Utilities / Hooks // ------------------------------------ -function useDebouncedValue(value, delay = 150) { - const [debounced, setDebounced] = useState(value); +function useDebouncedValue(value: T, delay = 150): T { + const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(t); @@ -90,7 +122,7 @@ function useDebouncedValue(value, delay = 150) { // ------------------------------------ // UI bits // ------------------------------------ -const Pill = React.memo(function Pill({ managed }) { +const Pill = memo(function Pill({ managed }: { managed: boolean }) { const pillBg = managed ? "bg-emerald-600 text-white ring-1 ring-emerald-300/30" : "bg-red-600 text-white ring-1 ring-red-300/30"; @@ -102,7 +134,7 @@ const Pill = React.memo(function Pill({ managed }) { ); }); -const AppLogo = React.memo(function AppLogo({ app, theme }) { +const AppLogo = memo(function AppLogo({ app, theme }: { app: App; theme: Theme }) { const bg = theme === "light" ? "bg-white" : "bg-black/20"; const themedSrc = app.id === "panel" ? (theme === "light" ? ASSETS.panel_black : ASSETS.panel_white) @@ -131,13 +163,18 @@ const AppLogo = React.memo(function AppLogo({ app, theme }) { ); }); -// Drag-and-drop capable card -const AppCard = React.memo(function AppCard({ +const AppCard = memo(function AppCard({ app, favorite = false, theme = "light", onToggleFavorite, dnd, +}: { + app: App; + favorite?: boolean; + theme?: Theme; + onToggleFavorite?: (id: string) => void; + dnd?: DndProps; }) { const draggable = !!(dnd && dnd.enabled); const isDragging = draggable && dnd && dnd.draggingId === app.id; @@ -146,11 +183,11 @@ const AppCard = React.memo(function AppCard({ className={`group relative rounded-2xl border border-white/10 bg-white/5 p-4 transform-gpu transition duration-200 ease-out cursor-default ${isDragging ? "opacity-0" : "hover:scale-[1.02] hover:shadow-[0_12px_32px_rgba(214,113,71,0.5)]"}`} data-brand={app.brandUrl || undefined} draggable={draggable} - onDragStart={draggable ? dnd.onDragStart(app.id, dnd.list) : undefined} - onDragOver={draggable ? dnd.onDragOver(app.id, dnd.list) : undefined} - onDrop={draggable ? dnd.onDrop(app.id, dnd.list) : undefined} - onDrag={draggable ? dnd.onDrag : undefined} - onDragEnd={draggable ? dnd.onDragEnd : undefined} + onDragStart={draggable ? dnd!.onDragStart(app.id, dnd!.list) : undefined} + onDragOver={draggable ? dnd!.onDragOver(app.id, dnd!.list) : undefined} + onDrop={draggable ? dnd!.onDrop(app.id, dnd!.list) : undefined} + onDrag={draggable ? dnd!.onDrag : undefined} + onDragEnd={draggable ? dnd!.onDragEnd : undefined} aria-grabbed={draggable ? undefined : false} > {/* Brand link (top-left) */} @@ -205,28 +242,32 @@ const AppCard = React.memo(function AppCard({ // Page // ------------------------------------ export default function FederatedDashboard() { - const [theme, setTheme] = useState("light"); + const [theme, setTheme] = useState("light"); const [query, setQuery] = useState(""); // Default favorite: Panel only - const [favorites, setFavorites] = useState(new Set(["panel"])); + const [favorites, setFavorites] = useState>(new Set(["panel"])); // Orders for within-section reordering const initialFav = new Set(["panel"]); - const [favoritesOrder, setFavoritesOrder] = useState(APP_CATALOG.map(a => a.id).filter(id => initialFav.has(id))); - const [othersOrder, setOthersOrder] = useState(APP_CATALOG.map(a => a.id).filter(id => !initialFav.has(id))); + const [favoritesOrder, setFavoritesOrder] = useState( + APP_CATALOG.map(a => a.id).filter(id => initialFav.has(id)) + ); + const [othersOrder, setOthersOrder] = useState( + APP_CATALOG.map(a => a.id).filter(id => !initialFav.has(id)) + ); // Drag state - const [dragging, setDragging] = useState(null); // { id, from } - const [dragPos, setDragPos] = useState(null); // { x, y } - const [dragMeta, setDragMeta] = useState(null); // { offsetX, offsetY, width, height, app } + const [dragging, setDragging] = useState(null); + const [dragPos, setDragPos] = useState(null); + const [dragMeta, setDragMeta] = useState(null); // CSS vars const rootStyle = useMemo(() => ({ "--brand-primary": theme === "light" ? LIGHT_TOKENS.primary : BRAND_TOKENS.primary, "--brand-accent": BRAND_TOKENS.accent, "--brand-surface": theme === "light" ? LIGHT_TOKENS.surface : BRAND_TOKENS.surface, - }), [theme]); + }) as React.CSSProperties, [theme]); // Index for search const APP_INDEX = useMemo( @@ -234,7 +275,7 @@ export default function FederatedDashboard() { [] ); const APP_MAP = useMemo(() => { - const m = new Map(); + const m = new Map(); APP_INDEX.forEach(a => m.set(a.id, a)); return m; }, [APP_INDEX]); @@ -246,7 +287,7 @@ export default function FederatedDashboard() { ); const isSearching = tokens.length > 0; - const matches = useCallback((a) => { + const matches = useCallback((a: App & { _hay: string }) => { if (tokens.length === 0) return true; const h = a._hay; for (const t of tokens) if (!h.includes(t)) return false; @@ -254,13 +295,13 @@ export default function FederatedDashboard() { }, [tokens]); // Lists in display order, then filtered - const favAll = useMemo(() => favoritesOrder.map(id => APP_MAP.get(id)).filter(Boolean), [favoritesOrder, APP_MAP]); - const otherAll = useMemo(() => othersOrder.map(id => APP_MAP.get(id)).filter(Boolean), [othersOrder, APP_MAP]); + const favAll = useMemo(() => favoritesOrder.map(id => APP_MAP.get(id)!).filter(Boolean), [favoritesOrder, APP_MAP]); + const otherAll = useMemo(() => othersOrder.map(id => APP_MAP.get(id)!).filter(Boolean), [othersOrder, APP_MAP]); const favList = useMemo(() => favAll.filter(matches), [favAll, matches]); const otherList = useMemo(() => otherAll.filter(matches), [otherAll, matches]); // Favorite toggle + sync orders - const toggleFavorite = useCallback((id) => { + const toggleFavorite = useCallback((id: string) => { setFavorites(prev => { const next = new Set(prev); const wasFav = next.has(id); @@ -272,12 +313,12 @@ export default function FederatedDashboard() { }, []); // DnD handlers (within-section only) + floating preview tracking - const onDragStart = useCallback((id, list) => (e) => { + const onDragStart = useCallback((id: string, list: DragList) => (e: React.DragEvent) => { setDragging({ id, from: list }); - const rect = e.currentTarget.getBoundingClientRect(); + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const offsetX = e.clientX - rect.left; const offsetY = e.clientY - rect.top; - setDragMeta({ offsetX, offsetY, width: rect.width, height: rect.height, app: APP_MAP.get(id) }); + setDragMeta({ offsetX, offsetY, width: rect.width, height: rect.height, app: APP_MAP.get(id)! }); setDragPos({ x: e.clientX, y: e.clientY }); e.dataTransfer.effectAllowed = "move"; // hide the native ghost image so only our custom preview shows @@ -286,7 +327,7 @@ export default function FederatedDashboard() { e.dataTransfer.setDragImage(img, 0, 0); }, [APP_MAP]); - const onDragOver = useCallback((overId, list) => (e) => { + const onDragOver = useCallback((_overId: string, list: DragList) => (e: React.DragEvent) => { if (!dragging || dragging.from !== list) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; @@ -295,19 +336,17 @@ export default function FederatedDashboard() { // keep preview following cursor even when not over a specific card useEffect(() => { if (!dragging) return; - const handle = (e) => { - setDragPos({ x: e.clientX, y: e.clientY }); - }; - window.addEventListener("dragover", handle); - return () => window.removeEventListener("dragover", handle); + const handle = (e: DragEvent) => { setDragPos({ x: e.clientX, y: e.clientY }); }; + window.addEventListener("dragover", handle as unknown as EventListener); + return () => window.removeEventListener("dragover", handle as unknown as EventListener); }, [dragging]); - const onDrag = useCallback((e) => { + const onDrag = useCallback((e: React.DragEvent) => { if (!dragging) return; setDragPos({ x: e.clientX, y: e.clientY }); }, [dragging]); - const onDrop = useCallback((overId, list) => (e) => { + const onDrop = useCallback((overId: string, list: DragList) => (e: React.DragEvent) => { e.preventDefault(); if (!dragging || dragging.from !== list) return; if (dragging.id === overId) { setDragging(null); setDragMeta(null); setDragPos(null); return; } @@ -321,7 +360,7 @@ export default function FederatedDashboard() { setDragging(null); setDragMeta(null); setDragPos(null); }, [dragging, favoritesOrder, othersOrder]); - const onDropToEnd = useCallback((list) => (e) => { + const onDropToEnd = useCallback((list: DragList) => (e: React.DragEvent) => { e.preventDefault(); if (!dragging || dragging.from !== list) return; const arr = list === "fav" ? favoritesOrder.slice() : othersOrder.slice(); @@ -342,7 +381,20 @@ export default function FederatedDashboard() { {/* Header */}
-
+ +

Dashboard

+