Fix: remove accidental nested Git repository
This commit is contained in:
		
							
								
								
									
										1
									
								
								federated-dashboard
									
									
									
									
									
										Submodule
									
								
							
							
								
								
								
								
								
							
						
						
									
										1
									
								
								federated-dashboard
									
									
									
									
									
										Submodule
									
								
							 Submodule federated-dashboard added at 1a88bb7ed9
									
								
							@@ -1,6 +1,5 @@
 | 
				
			|||||||
import React from "react";
 | 
					 | 
				
			||||||
import FederatedDashboard from "./FederatedDashboard";
 | 
					import FederatedDashboard from "./FederatedDashboard";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function App() {
 | 
					export default function App() {
 | 
				
			||||||
  return <FederatedDashboard />;
 | 
					  return <FederatedDashboard />;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user