Fix: remove accidental nested Git repository

This commit is contained in:
2025-09-03 15:48:32 -06:00
parent 1a88bb7ed9
commit f7a02d2c62
3 changed files with 127 additions and 52 deletions

1
federated-dashboard Submodule

Submodule federated-dashboard added at 1a88bb7ed9

View File

@@ -1,4 +1,3 @@
import React from "react";
import FederatedDashboard from "./FederatedDashboard"; import FederatedDashboard from "./FederatedDashboard";
export default function App() { export default function App() {

View File

@@ -1,9 +1,11 @@
// Federated Dashboard — React + Tailwind + Font Awesome 6 Free (Canvas Preview JS) // Final Federated Dashboard — React + Tailwind + Font Awesome 6 Free (Type-safe)
// Adds custom drag preview that follows the cursor with a blue (#0EA5E9 @ 50%) shadow. // Features: theme toggle, favorites, debounced search, drag & drop reordering within sections,
// Features: theme toggle, favorites, search (headings hidden while typing), drag & drop reordering, // custom floating drag preview with blue (#0EA5E9 @ 50%) shadow, status pill, 4-col grid,
// custom floating preview, status pill, 4-col grid, centered Federated logo. // 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons"; import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
import { import {
@@ -13,6 +15,7 @@ import {
faMagnifyingGlass, faMagnifyingGlass,
faStar as faStarSolid, faStar as faStarSolid,
faXmark, faXmark,
faComments, // icon for Documentation button
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
// ------------------------------------ // ------------------------------------
@@ -20,7 +23,7 @@ import {
// ------------------------------------ // ------------------------------------
const BRAND_TOKENS = { const BRAND_TOKENS = {
primary: "#0F172A", // dark theme background primary: "#0F172A", // dark theme background
accent: "#D67147", // favorites + focus ring + section label color accent: "#D67147", // favorites + focus ring + section label color
surface: "#1E293B", surface: "#1E293B",
}; };
const LIGHT_TOKENS = { 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<HTMLDivElement>) => void;
onDragOver: (_overId: string, list: DragList) => (e: React.DragEvent<HTMLDivElement>) => void;
onDrop: (overId: string, list: DragList) => (e: React.DragEvent<HTMLDivElement>) => void;
onDrag: (e: React.DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
draggingId?: string | null;
}
// ------------------------------------
// Data (local assets in public/assets)
// ------------------------------------ // ------------------------------------
// Local asset files for app logos (place in public/assets/)
const ASSETS = { const ASSETS = {
panel_black: "/assets/panelblack.png", panel_black: "/assets/panelblack.png",
panel_white: "/assets/panelwhite.png", panel_white: "/assets/panelwhite.png",
@@ -56,7 +88,7 @@ const ASSETS = {
federated_logo_dark: "/assets/federated.png", 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: "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: "jitsi", name: "Jitsi (Meet)", category: "Video Chat", panelManaged: true, logoUrl: ASSETS.jitsi },
{ id: "element", name: "Element", category: "Team Chat", panelManaged: true, logoUrl: ASSETS.element }, { 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) { function useDebouncedValue<T>(value: T, delay = 150): T {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState<T>(value);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay); const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t); return () => clearTimeout(t);
@@ -90,7 +122,7 @@ function useDebouncedValue(value, delay = 150) {
// ------------------------------------ // ------------------------------------
// UI bits // UI bits
// ------------------------------------ // ------------------------------------
const Pill = React.memo(function Pill({ managed }) { const Pill = memo(function Pill({ managed }: { managed: boolean }) {
const pillBg = managed const pillBg = managed
? "bg-emerald-600 text-white ring-1 ring-emerald-300/30" ? "bg-emerald-600 text-white ring-1 ring-emerald-300/30"
: "bg-red-600 text-white ring-1 ring-red-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 bg = theme === "light" ? "bg-white" : "bg-black/20";
const themedSrc = app.id === "panel" const themedSrc = app.id === "panel"
? (theme === "light" ? ASSETS.panel_black : ASSETS.panel_white) ? (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 = memo(function AppCard({
const AppCard = React.memo(function AppCard({
app, app,
favorite = false, favorite = false,
theme = "light", theme = "light",
onToggleFavorite, onToggleFavorite,
dnd, dnd,
}: {
app: App;
favorite?: boolean;
theme?: Theme;
onToggleFavorite?: (id: string) => void;
dnd?: DndProps;
}) { }) {
const draggable = !!(dnd && dnd.enabled); const draggable = !!(dnd && dnd.enabled);
const isDragging = draggable && dnd && dnd.draggingId === app.id; 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)]"}`} 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} data-brand={app.brandUrl || undefined}
draggable={draggable} draggable={draggable}
onDragStart={draggable ? dnd.onDragStart(app.id, dnd.list) : undefined} onDragStart={draggable ? dnd!.onDragStart(app.id, dnd!.list) : undefined}
onDragOver={draggable ? dnd.onDragOver(app.id, dnd.list) : undefined} onDragOver={draggable ? dnd!.onDragOver(app.id, dnd!.list) : undefined}
onDrop={draggable ? dnd.onDrop(app.id, dnd.list) : undefined} onDrop={draggable ? dnd!.onDrop(app.id, dnd!.list) : undefined}
onDrag={draggable ? dnd.onDrag : undefined} onDrag={draggable ? dnd!.onDrag : undefined}
onDragEnd={draggable ? dnd.onDragEnd : undefined} onDragEnd={draggable ? dnd!.onDragEnd : undefined}
aria-grabbed={draggable ? undefined : false} aria-grabbed={draggable ? undefined : false}
> >
{/* Brand link (top-left) */} {/* Brand link (top-left) */}
@@ -205,28 +242,32 @@ const AppCard = React.memo(function AppCard({
// Page // Page
// ------------------------------------ // ------------------------------------
export default function FederatedDashboard() { export default function FederatedDashboard() {
const [theme, setTheme] = useState("light"); const [theme, setTheme] = useState<Theme>("light");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// Default favorite: Panel only // Default favorite: Panel only
const [favorites, setFavorites] = useState(new Set(["panel"])); const [favorites, setFavorites] = useState<Set<string>>(new Set(["panel"]));
// Orders for within-section reordering // Orders for within-section reordering
const initialFav = new Set(["panel"]); const initialFav = new Set(["panel"]);
const [favoritesOrder, setFavoritesOrder] = useState(APP_CATALOG.map(a => a.id).filter(id => initialFav.has(id))); const [favoritesOrder, setFavoritesOrder] = useState<string[]>(
const [othersOrder, setOthersOrder] = useState(APP_CATALOG.map(a => a.id).filter(id => !initialFav.has(id))); APP_CATALOG.map(a => a.id).filter(id => initialFav.has(id))
);
const [othersOrder, setOthersOrder] = useState<string[]>(
APP_CATALOG.map(a => a.id).filter(id => !initialFav.has(id))
);
// Drag state // Drag state
const [dragging, setDragging] = useState(null); // { id, from } const [dragging, setDragging] = useState<DragState | null>(null);
const [dragPos, setDragPos] = useState(null); // { x, y } const [dragPos, setDragPos] = useState<DragPos | null>(null);
const [dragMeta, setDragMeta] = useState(null); // { offsetX, offsetY, width, height, app } const [dragMeta, setDragMeta] = useState<DragMeta | null>(null);
// CSS vars // CSS vars
const rootStyle = useMemo(() => ({ const rootStyle = useMemo(() => ({
"--brand-primary": theme === "light" ? LIGHT_TOKENS.primary : BRAND_TOKENS.primary, "--brand-primary": theme === "light" ? LIGHT_TOKENS.primary : BRAND_TOKENS.primary,
"--brand-accent": BRAND_TOKENS.accent, "--brand-accent": BRAND_TOKENS.accent,
"--brand-surface": theme === "light" ? LIGHT_TOKENS.surface : BRAND_TOKENS.surface, "--brand-surface": theme === "light" ? LIGHT_TOKENS.surface : BRAND_TOKENS.surface,
}), [theme]); }) as React.CSSProperties, [theme]);
// Index for search // Index for search
const APP_INDEX = useMemo( const APP_INDEX = useMemo(
@@ -234,7 +275,7 @@ export default function FederatedDashboard() {
[] []
); );
const APP_MAP = useMemo(() => { const APP_MAP = useMemo(() => {
const m = new Map(); const m = new Map<string, App & { _hay: string }>();
APP_INDEX.forEach(a => m.set(a.id, a)); APP_INDEX.forEach(a => m.set(a.id, a));
return m; return m;
}, [APP_INDEX]); }, [APP_INDEX]);
@@ -246,7 +287,7 @@ export default function FederatedDashboard() {
); );
const isSearching = tokens.length > 0; const isSearching = tokens.length > 0;
const matches = useCallback((a) => { const matches = useCallback((a: App & { _hay: string }) => {
if (tokens.length === 0) return true; if (tokens.length === 0) return true;
const h = a._hay; const h = a._hay;
for (const t of tokens) if (!h.includes(t)) return false; for (const t of tokens) if (!h.includes(t)) return false;
@@ -254,13 +295,13 @@ export default function FederatedDashboard() {
}, [tokens]); }, [tokens]);
// Lists in display order, then filtered // Lists in display order, then filtered
const favAll = useMemo(() => favoritesOrder.map(id => APP_MAP.get(id)).filter(Boolean), [favoritesOrder, 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 otherAll = useMemo(() => othersOrder.map(id => APP_MAP.get(id)!).filter(Boolean), [othersOrder, APP_MAP]);
const favList = useMemo(() => favAll.filter(matches), [favAll, matches]); const favList = useMemo(() => favAll.filter(matches), [favAll, matches]);
const otherList = useMemo(() => otherAll.filter(matches), [otherAll, matches]); const otherList = useMemo(() => otherAll.filter(matches), [otherAll, matches]);
// Favorite toggle + sync orders // Favorite toggle + sync orders
const toggleFavorite = useCallback((id) => { const toggleFavorite = useCallback((id: string) => {
setFavorites(prev => { setFavorites(prev => {
const next = new Set(prev); const next = new Set(prev);
const wasFav = next.has(id); const wasFav = next.has(id);
@@ -272,12 +313,12 @@ export default function FederatedDashboard() {
}, []); }, []);
// DnD handlers (within-section only) + floating preview tracking // DnD handlers (within-section only) + floating preview tracking
const onDragStart = useCallback((id, list) => (e) => { const onDragStart = useCallback((id: string, list: DragList) => (e: React.DragEvent<HTMLDivElement>) => {
setDragging({ id, from: list }); setDragging({ id, from: list });
const rect = e.currentTarget.getBoundingClientRect(); const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const offsetX = e.clientX - rect.left; const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top; 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 }); setDragPos({ x: e.clientX, y: e.clientY });
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
// hide the native ghost image so only our custom preview shows // 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); e.dataTransfer.setDragImage(img, 0, 0);
}, [APP_MAP]); }, [APP_MAP]);
const onDragOver = useCallback((overId, list) => (e) => { const onDragOver = useCallback((_overId: string, list: DragList) => (e: React.DragEvent<HTMLDivElement>) => {
if (!dragging || dragging.from !== list) return; if (!dragging || dragging.from !== list) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
@@ -295,19 +336,17 @@ export default function FederatedDashboard() {
// keep preview following cursor even when not over a specific card // keep preview following cursor even when not over a specific card
useEffect(() => { useEffect(() => {
if (!dragging) return; if (!dragging) return;
const handle = (e) => { const handle = (e: DragEvent) => { setDragPos({ x: e.clientX, y: e.clientY }); };
setDragPos({ x: e.clientX, y: e.clientY }); window.addEventListener("dragover", handle as unknown as EventListener);
}; return () => window.removeEventListener("dragover", handle as unknown as EventListener);
window.addEventListener("dragover", handle);
return () => window.removeEventListener("dragover", handle);
}, [dragging]); }, [dragging]);
const onDrag = useCallback((e) => { const onDrag = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!dragging) return; if (!dragging) return;
setDragPos({ x: e.clientX, y: e.clientY }); setDragPos({ x: e.clientX, y: e.clientY });
}, [dragging]); }, [dragging]);
const onDrop = useCallback((overId, list) => (e) => { const onDrop = useCallback((overId: string, list: DragList) => (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
if (!dragging || dragging.from !== list) return; if (!dragging || dragging.from !== list) return;
if (dragging.id === overId) { setDragging(null); setDragMeta(null); setDragPos(null); 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); setDragging(null); setDragMeta(null); setDragPos(null);
}, [dragging, favoritesOrder, othersOrder]); }, [dragging, favoritesOrder, othersOrder]);
const onDropToEnd = useCallback((list) => (e) => { const onDropToEnd = useCallback((list: DragList) => (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
if (!dragging || dragging.from !== list) return; if (!dragging || dragging.from !== list) return;
const arr = list === "fav" ? favoritesOrder.slice() : othersOrder.slice(); const arr = list === "fav" ? favoritesOrder.slice() : othersOrder.slice();
@@ -342,7 +381,20 @@ export default function FederatedDashboard() {
{/* Header */} {/* Header */}
<header className="sticky top-0 z-40 border-b border-white/10 bg-[var(--brand-primary)]/95 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-white/10 bg-[var(--brand-primary)]/95 backdrop-blur">
<div className="mx-auto grid max-w-7xl grid-cols-3 items-center px-4 py-3"> <div className="mx-auto grid max-w-7xl grid-cols-3 items-center px-4 py-3">
<div className="justify-self-start"></div> <div className="justify-self-start">
<a
href="https://discourse.federated.computer"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-[var(--brand-accent)]"
aria-label="Open Federated Documentation (new tab)"
title="Documentation"
>
<FontAwesomeIcon icon={faComments} />
<span>Documentation</span>
</a>
</div>
<div className="flex flex-col items-center gap-1 leading-none text-center justify-self-center"> <div className="flex flex-col items-center gap-1 leading-none text-center justify-self-center">
<img <img
src={theme === "light" ? ASSETS.federated_logo_light : ASSETS.federated_logo_dark} src={theme === "light" ? ASSETS.federated_logo_light : ASSETS.federated_logo_dark}
@@ -351,6 +403,7 @@ export default function FederatedDashboard() {
/> />
<h1 className="mt-1 text-base sm:text-xl font-semibold leading-none tracking-tight">Dashboard</h1> <h1 className="mt-1 text-base sm:text-xl font-semibold leading-none tracking-tight">Dashboard</h1>
</div> </div>
<div className="flex items-center gap-2 justify-self-end"> <div className="flex items-center gap-2 justify-self-end">
<button <button
onClick={() => setTheme("light")} onClick={() => setTheme("light")}
@@ -414,7 +467,11 @@ export default function FederatedDashboard() {
) : ( ) : (
<> <>
{/* Favorites */} {/* Favorites */}
<section className="mx-auto max-w-7xl px-4 py-6" onDragOver={(e) => dragging && dragging.from === "fav" && e.preventDefault()} onDrop={onDropToEnd("fav")}> <section
className="mx-auto max-w-7xl px-4 py-6"
onDragOver={(e) => dragging && dragging.from === "fav" && e.preventDefault()}
onDrop={onDropToEnd("fav")}
>
<h2 className={`mb-3 text-sm font-semibold uppercase tracking-wider ${theme === "light" ? "text-white" : "text-[var(--brand-accent)]"}`}>Favorites</h2> <h2 className={`mb-3 text-sm font-semibold uppercase tracking-wider ${theme === "light" ? "text-white" : "text-[var(--brand-accent)]"}`}>Favorites</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{favList.map((app) => ( {favList.map((app) => (
@@ -436,7 +493,11 @@ export default function FederatedDashboard() {
</section> </section>
{/* Your Apps (non-favorites) */} {/* Your Apps (non-favorites) */}
<section className="mx-auto max-w-7xl px-4 py-6" onDragOver={(e) => dragging && dragging.from === "other" && e.preventDefault()} onDrop={onDropToEnd("other") }> <section
className="mx-auto max-w-7xl px-4 py-6"
onDragOver={(e) => dragging && dragging.from === "other" && e.preventDefault()}
onDrop={onDropToEnd("other")}
>
<h2 className={`mb-3 text-sm font-semibold uppercase tracking-wider ${theme === "light" ? "text-white" : "text-[var(--brand-accent)]"}`}>Your Apps</h2> <h2 className={`mb-3 text-sm font-semibold uppercase tracking-wider ${theme === "light" ? "text-white" : "text-[var(--brand-accent)]"}`}>Your Apps</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{otherList.map((app) => ( {otherList.map((app) => (
@@ -490,4 +551,18 @@ export default function FederatedDashboard() {
); );
} }
// NOTE: Dev-only sanity checks were removed to maximize Canvas compatibility. // Dev-only sanity checks (Node-agnostic)
const __DEV__ = (typeof globalThis !== "undefined" && (globalThis as any)?.process?.env?.NODE_ENV !== "production");
if (__DEV__) {
try {
const ids = new Set<string>();
for (const a of APP_CATALOG) {
if (!a.id || !a.name || !a.category) throw new Error("App missing id/name/category");
if (ids.has(a.id)) throw new Error("Duplicate app id: " + a.id);
ids.add(a.id);
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[Dashboard] sanity check:", e);
}
}