diff --git a/.github/instructions/admin-ui.instructions.md b/.github/instructions/admin-ui.instructions.md index 6304259156..29fee09894 100644 --- a/.github/instructions/admin-ui.instructions.md +++ b/.github/instructions/admin-ui.instructions.md @@ -1,55 +1,369 @@ --- applyTo: "packages/admin-ui/src/**/*.ts,packages/admin-ui/src/**/*.tsx,packages/admin-ui/src/**/*.js,packages/admin-ui/src/**/*.scss" --- + # Copilot Instructions for admin-ui ## Overview + - The `admin-ui` module is the React-based frontend for Dappnode Package Manager. - All code should be written using TypeScript and React best practices. +- The project is migrating from Bootstrap/SCSS (legacy) to Tailwind CSS v4 + shadcn/ui (new pages). +- **Legacy pages** live in `src/pages/` — they must never be modified. +- **New pages** live in `src/pages-new/` — all new development happens here. ## Directory Structure Guidelines -- **Entry Points:** - - Main app entry: `src/App.tsx` - - React root: `src/index.tsx` -- **Pages:** - - Add new UI views under `src/pages/`. Use PascalCase for file names (e.g., `UserManagement.tsx`). - - For wizard or onboarding flows, use `src/start-pages/`. -- **Components:** - - Reusable components go in `src/components/`. Use PascalCase for component files. -- **Hooks:** - - Custom React hooks should be placed in `src/hooks/` and named with the `use` prefix (e.g., `useUser.ts`). -- **API layer:** - - API calls and logic reside in `src/api/` and `src/services/`. - - Always use functions from these directories when making backend requests. -- **Utils:** - - Utility helpers should go in `src/utils/`. -- **State Management:** - - Use `src/store.ts` for Redux or main store logic, and `src/rootReducer.ts` for reducers. -- **Types:** - - Shared/global types should go in `src/types.ts`. -- **Mock Backend:** - - For tests and development, use `src/__mock-backend__/`. -- **Tests:** - - Place all tests in `src/__tests__/`. + +### Entry Points + +- Main app entry: `src/App.tsx` +- React root: `src/index.tsx` + +### Pages + +- **New pages**: Create under `src/pages-new/`, organised by context folder: + - `src/pages-new/home/` — Home / landing pages + - `src/pages-new/ai/` — AI section pages + - Add new context folders as needed (e.g. `src/pages-new/staking/`) +- **Legacy pages** (`src/pages/`): **Do not modify**. They use Bootstrap/SCSS and are served under `/staking`. +- For wizard or onboarding flows, use `src/start-pages/`. + +### Layouts + +The project uses a two-tier layout system: + +#### Shared base layout: `SectionLayout` (`src/layouts/SectionLayout.tsx`) + +All new sections use `SectionLayout` as their base. It provides: +- `.tw-base` scoped reset +- Collapsible sidebar with configurable nav items +- Sticky topbar with breadcrumbs and theme toggle +- `DecorativeBackground` gradient orbs +- Sonner `` +- A "Back to Home" sidebar link (auto-hidden when `basePath="/"`) + +`SectionLayout` is configured via props: + +| Prop | Type | Description | +| -------------- | ----------- | ---------------------------------------------------------- | +| `sectionLabel` | `string` | Shown in sidebar subtitle and breadcrumb root (e.g. "AI"). | +| `basePath` | `string` | Route prefix for breadcrumb links (e.g. `"/ai"`, `"/"`). | +| `navItems` | `NavItem[]` | `{ label, icon, path }` entries rendered in the sidebar. | +| `children` | `ReactNode` | Typically a `` block with the section's pages. | + +Import from the barrel: `import { SectionLayout, NavItem } from "layouts";` + +#### Section-specific layouts + +Each route section has a thin layout wrapper that passes configuration to `SectionLayout`: + +- **`HomeLayout`** (`src/pages-new/home/HomeLayout.tsx`) — `basePath="/"`, tabs: Home, System Info, Settings. +- **`AiLayout`** (`src/pages-new/ai/AiLayout.tsx`) — `basePath="/ai"`, tabs: Packages, Store, Nexus. +- **`LegacyStakingLayout`** (`src/layouts/LegacyStakingLayout.tsx`) — Bootstrap-based, does **not** use `SectionLayout`. + +#### `DecorativeBackground` (`src/layouts/DecorativeBackground.tsx`) + +Purely presentational gradient-orb layer used by `SectionLayout` and `NewPageLayout`. Import from `layouts/DecorativeBackground`. + +#### `NewPageLayout` (`src/pages-new/layouts/NewPageLayout.tsx`) + +Standalone page wrapper for pages outside any section (Login, Register, NoConnection). Provides `.tw-base`, `min-h-screen`, `bg-background`, and optional decorative background. **Not** used for pages inside a section — those inherit everything from `SectionLayout`. + +#### Adding a new section + +1. Create a folder under `src/pages-new/[section]/`. +2. Create `[Section]Layout.tsx`: + ```tsx + import { SectionLayout, NavItem } from "layouts"; + + const navItems: NavItem[] = [ + { label: "Tab One", icon: SomeIcon, path: "/section/tab-one" }, + ]; + + export function SectionNameLayout() { + return ( + + + } /> + + + ); + } + ``` +3. Add a `} />` in `App.tsx`. + +### Components + +- **Primitives** (shadcn components): `src/components/primitives/` (button, card, sidebar, sheet, tooltip, etc.) +- **Reusable app components**: `src/components/`. Use PascalCase for component files. + +### Hooks + +- Custom React hooks: `src/hooks/` with `use` prefix (e.g., `useUser.ts`). +- shadcn-specific hooks: `src/hooks/components/` (e.g., `use-mobile.ts`). + +### Other Directories + +- **API layer**: `src/api/` and `src/services/` — always use these for backend requests. +- **Utils**: `src/utils/` +- **Lib**: `src/lib/` — contains `utils.ts` with the `cn()` helper (clsx + tailwind-merge). +- **State Management**: `src/store.ts` for Redux, `src/rootReducer.ts` for reducers. +- **Types**: `src/types.ts` for shared/global types. +- **Mock Backend**: `src/__mock-backend__/` +- **Tests**: `src/__tests__/` + +## Import Conventions + +This project uses `baseUrl: "src"` in `tsconfig.json` with `vite-tsconfig-paths` for Vite resolution. + +**Always use bare imports** (relative to `src/`): ## Styling -- Use SCSS for styling. - - Common styles: `src/dappnode_styles.scss`, `src/dappnode_colors.scss`, `src/layout.scss`, `src/light_dark.scss`. - - Prefer CSS modules or SCSS imports over inline styles. + +### New Pages (Tailwind CSS v4 + shadcn) + +- All Tailwind classes use the `tw:` prefix (e.g., `tw:flex`, `tw:bg-primary`). +- Design tokens are defined in `src/styles/tailwind.css`. +- The `.tw-base` class provides a scoped CSS reset (box-sizing, font-family, etc.) since Tailwind preflight is disabled to avoid breaking legacy Bootstrap styles. +- Pages inside a section (Home, AI, etc.) get `.tw-base` automatically from `SectionLayout` — no extra wrapper needed. +- Standalone pages (Login, Register, NoConnection) must use `` from `pages-new/layouts`, which provides `.tw-base` + optional decorative background. + +### Legacy Pages (Bootstrap/SCSS) + +- Common styles: `src/dappnode_styles.scss`, `src/dappnode_colors.scss`, `src/layout.scss`, `src/light_dark.scss`. +- Bootstrap is scoped under `.legacy-bootstrap` via `src/styles/bootstrap-scoped.scss`. +- Do not use Tailwind classes in legacy pages. + +## Design Tokens and Layout Consistency + +All design tokens are defined in `src/styles/tailwind.css`. **Always reuse existing tokens** when building layouts or composing pages. + +### Layout Spacing Tokens + +These tokens are available as Tailwind spacing utilities: + +| Token | CSS Variable | Default | Usage | +| ---------------------- | ------------------ | -------- | ------------------------------------- | +| `tw:px-page-x` | `--page-padding-x` | `1.5rem` | Horizontal page padding | +| `tw:py-page-y` | `--page-padding-y` | `2rem` | Vertical page padding | +| `tw:gap-section` | `--section-gap` | `2rem` | Gap between major page sections | +| `tw:gap-card` | `--card-gap` | `1rem` | Gap between cards / list items | +| `tw:mt-header-gap` | `--header-gap` | `0.5rem` | Space between heading and description | +| `tw:h-topbar-h` | `--topbar-height` | `3rem` | Height of top bars / app bars | +| `tw:max-w-content-max` | `--content-max-w` | `64rem` | Max width for main content areas | + +### Brand Colors + +Available as `tw:bg-dn-blue`, `tw:text-dn-cyan`, etc.: + +- Blue `#00B1F4`, Cyan `#06D4E7`, Orange `#FC9E22`, Pink `#E60AF6`, Purple `#5231C6` + +### Semantic Colors (shadcn) + +`primary`, `secondary`, `muted`, `accent`, `destructive`, `card`, `popover`, `sidebar-*`, etc. — all defined in `tailwind.css` with light/dark variants. + +### Rules + +- Always reuse existing tokens. Do not hardcode spacing values that already have a token. +- If a new token is needed, add it to `tailwind.css` following the existing pattern (CSS variable in `:root` + `--spacing-*` mapping in `@theme inline`). +- Components may still use specific margins/paddings as exceptions when the token doesn't apply. + +## shadcn Component Installation + +### Installing a New Component + +1. Navigate to `packages/admin-ui`. +2. Run: + ```bash + yarn dlx shadcn@latest add [COMPONENT_NAME] + ``` + Components will be placed in `src/components/primitives/` (configured via `components.json`). + +### Required Post-Install Fixes + +After importing any shadcn component, apply these fixes before the code will compile: + +1. **Replace `@/` imports with bare imports.** shadcn generates `@/` prefixed imports which fail in the Docker build. Change them to bare imports: + + ```ts + // Generated by shadcn: + import { cn } from "@/lib/utils"; + // Fix to: + import { cn } from "lib/utils"; + ``` + +```ts +// ✅ Correct +import { Button } from "components/primitives/button"; +import { cn } from "lib/utils"; +import { useIsMobile } from "hooks/components/use-mobile"; + +// ❌ Wrong — @/ prefix fails in Docker builds +import { Button } from "@/components/primitives/button"; +``` + +Apply this to all `@/` prefixed imports in the generated file (`lib/utils`, `components/primitives/*`, `hooks/components/*`). + +2. **Add `import * as React from "react"`.** Some generated components (e.g., `skeleton.tsx`) may be missing the React import. Add it at the top if the file uses JSX or `React.*` APIs. + +3. **Fix `Slot.Root` ref type mismatches.** Components that use Radix UI's `Slot.Root` with `asChild` pattern (like `sidebar.tsx`) produce TypeScript errors due to ref type incompatibility with React 18. Fix by casting: + + ```ts + // Generated: + const Comp = asChild ? Slot.Root : "button"; + // Fix to: + const Comp = (asChild ? Slot.Root : "button") as React.ElementType; + ``` + +4. **Verify the `"use client"` directive.** Some components include `"use client"` at the top. This is harmless in a non-RSC environment (this project uses `rsc: false` in `components.json`) and can be left in place. + +## Page Creation Rules + +When creating a new page inside an existing section: + +1. **Place it inside `src/pages-new/[section]/`** in the appropriate context folder. +2. **Use the page layout primitives** from `components/primitives/page` for consistent structure. +3. **Add a ``** in the section's layout file (e.g. `AiLayout.tsx` or `HomeLayout.tsx`). +4. **Add a nav item** to the section's `navItems` array if it should appear in the sidebar. +5. **Never modify legacy pages** in `src/pages/`. + +Pages inside a section do **not** need a `` or `.tw-base` wrapper — `SectionLayout` provides both automatically. + +For standalone pages outside any section (Login, Register, NoConnection), wrap with `` from `pages-new/layouts`. + +### Page Layout Primitives + +The project provides layout primitives in `src/components/primitives/page.tsx` that **must** be used for all page-level structure: + +| Component | Purpose | +| ----------------- | --------------------------------------------------------- | +| `PageContainer` | Outermost wrapper — applies `px-page-x`, `py-page-y`, `gap-section`, flexbox column layout. Replaces the repeated `
` pattern. | +| `PageHeader` | Semantic `
` with optional `title` and `description` shorthand props. Also accepts `children` for composed content. | +| `PageTitle` | Wraps `TypographyH3` (`

`, 2xl, semibold, tracking-tight) for consistent page headings. | +| `PageDescription` | Wraps `TypographyMuted` with `mt-header-gap` spacing and `max-w-2xl`. | + +**Do not** duplicate the container/header pattern with raw HTML + utility classes — always use these primitives. + +### Standard Page Structure + +```tsx +import { PageContainer, PageHeader } from "components/primitives/page"; + +export function MyPage() { + return ( + + + {/* Page content */} + + ); +} +``` + +### Composed Header (custom content) + +```tsx +import { PageContainer, PageHeader, PageTitle, PageDescription } from "components/primitives/page"; + +export function MyPage() { + return ( + + + My Page + Description with rich content. + + + {/* Page content */} + + ); +} +``` + +### PageContainer with custom gap + +The detail page uses `` to override the default `gap-section` when tighter spacing is needed (e.g., for tab layouts). ## API Integration -- Use api instance from `src/api/index.ts` for making api calls. -- Use `useApi` instance from `src/api/index.ts` for making api calls with SWR. + +- Use `api` instance from `src/api/index.ts` for imperative calls (mutations, actions). +- Use `useApi` hook from `src/api/index.ts` for data fetching with SWR (auto-revalidation). +- Use `apiRoutes` from `src/api/index.ts` for URL helpers (e.g., `apiRoutes.containerLogsUrl`, `apiRoutes.fileDownloadUrl`, `apiRoutes.downloadUrl`, `apiRoutes.uploadFile`). +- For toasts in new pages, use **Sonner** (`import { toast } from "sonner"`), not the legacy `withToast`/`withToastNoThrow`. +- For destructive confirmation dialogs, use the `AlertDialog` primitive, not the legacy `confirm()` helper. + +### Toast Pattern for Async Operations + +When showing a loading toast during an async operation, **always capture the toast ID** returned by `toast.loading()` and pass it to the subsequent `toast.success()` or `toast.error()` via `{ id: toastId }`. This replaces the loading toast instead of stacking a new one on top (which leaves the loading spinner visible forever). + +```tsx +// ✅ Correct — loading toast is replaced by success/error +async function handleAction() { + const toastId = toast.loading("Performing action..."); + try { + await api.someCall(); + toast.success("Action completed", { id: toastId }); + } catch (e) { + toast.error(`Error: ${e instanceof Error ? e.message : String(e)}`, { id: toastId }); + } +} + +// ❌ Wrong — loading toast stays visible after success/error appears +async function handleAction() { + try { + toast.loading("Performing action..."); + await api.someCall(); + toast.success("Action completed"); + } catch (e) { + toast.error(`Error: ${e instanceof Error ? e.message : String(e)}`); + } +} +``` + +**Key rules:** +- Declare `const toastId = toast.loading(...)` **before** the `try` block (or at the top of it) so it's in scope for the `catch` block. +- Pass `{ id: toastId }` to every `toast.success()` and `toast.error()` that should dismiss the loading toast. +- For functions with multiple sequential async steps (each with their own loading state), use separate toast IDs for each step. + +## Sidebar & Routing + +Each section (`/`, `/ai/*`, etc.) has its own thin layout that passes nav items and routes to the shared `SectionLayout`. To add a new page to an existing section: + +1. Create the page component under `src/pages-new/[section]/[context]/`. +2. Add a `` entry in the section's layout file (e.g. `AiLayout.tsx`, `HomeLayout.tsx`) inside the `` block. +3. To make it appear in the sidebar, add an entry to the section's `navItems` array. +4. The `isActive` check uses exact match + `startsWith` prefix matching — sub-routes are automatically highlighted. + +### Package Detail Page Pattern + +For pages with sub-tabs (like package detail), use this pattern: + +- The parent route uses a wildcard: `} />` +- Inside the detail page, use the `NavigationMenu` component (from `components/primitives/navigation-menu`) with `NavigationMenuLink asChild` wrapping React Router `NavLink` elements for the tab bar. +- Each tab is a nested `` rendered inside the detail component. +- Include a `` fallback to redirect to the default tab. ## UI/UX Guidelines -- Use shared components from `src/components/` whenever possible. + +- Use shared components from `src/components/` and `src/components/primitives/` whenever possible. - Ensure accessibility: use semantic HTML, proper ARIA attributes, and keyboard navigation. -- When adding new pages, update navigation menus as required (in `src/components/Sidebar.tsx` or similar). +- Use `lucide-react` for icons (the project's configured icon library). +- Use `prettyDnpName()` from `utils/format` to display human-readable package names. +- Use `parseContainerState()` from `pages/packages/components/StateBadge/utils` for container status display (returns `{variant, state, title}`). +- For navigation between new and legacy pages, use `withLegacyBase(path)` from `utils/path` which prepends the legacy route base (`/staking`). + +## Typography Primitives + +The project provides typography components in `src/components/primitives/typography.tsx`: + +- `TypographyH1` through `TypographyH4` — heading elements with consistent styling. +- `TypographyMuted` — muted paragraph text for descriptions. +- `TypographyInlineCode` — inline code snippets. + +**For page-level titles and descriptions**, prefer the page layout primitives (`PageTitle`, `PageDescription`) from `components/primitives/page` — they wrap `TypographyH3` and `TypographyMuted` respectively, adding consistent spacing tokens. Use the typography primitives directly for content-level headings within cards, sections, or prose. ## Coding Conventions + - Use explicit TypeScript types; avoid `any`. - Use hooks for all state and effect logic. - Keep components focused and reusable. - Prefer functional components. - Avoid placing business logic in UI components—delegate to hooks, services, or utilities. - diff --git a/Dockerfile.dev b/Dockerfile.dev index 1f28649749..6fb99d1a8f 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -21,7 +21,7 @@ ENV COMPOSE_HTTP_TIMEOUT=300 \ WORKDIR /app RUN apk update && apk add --no-cache docker curl docker-cli-compose xz zip unzip libltdl bash git bind bind-tools bind-dev \ - miniupnpc dbus tmux avahi-tools + miniupnpc dbus tmux avahi-tools inotify-tools RUN corepack enable # Copy git data @@ -29,6 +29,8 @@ COPY --from=git-data /usr/src/app/.git-data.json $GIT_DATA_PATH COPY package.json yarn.lock .yarnrc.yml tsconfig.json ./ COPY packages packages -ENTRYPOINT ["/bin/sh", "-c", "mkdir -p /app/packages/dappmanager/dnp_repo /app/packages/dappmanager/DNCORE && \ +ENTRYPOINT ["/bin/bash", "-c", "mkdir -p /app/packages/dappmanager/dnp_repo /app/packages/dappmanager/DNCORE && \ ln -sf /app/packages/admin-ui/build/ /app/packages/dappmanager/dist && \ - yarn && yarn build && yarn run dev"] \ No newline at end of file + yarn && yarn build && \ + bash /app/packages/admin-ui/scripts/tw-watch.sh & \ + cd /app && yarn run dev"] \ No newline at end of file diff --git a/packages/admin-ui/components.json b/packages/admin-ui/components.json new file mode 100644 index 0000000000..b2898dfcd8 --- /dev/null +++ b/packages/admin-ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/tailwind.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "tw" + }, + "aliases": { + "components": "@/components", + "ui": "@/components/primitives", + "lib": "@/lib", + "utils": "@/lib/utils", + "hooks": "@/hooks/components" + }, + "iconLibrary": "lucide" +} diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index a0cac306cb..d411f21365 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -12,7 +12,8 @@ "server-mock:check-types": "tsc --noEmit --project tsconfig.server-mock.json", "mock-standalone": "VITE_APP_MOCK=true yarn start", "mock-standalone:build": "VITE_APP_MOCK=true yarn build", - "dev": "VITE_APP_API_TEST=true vite build --watch" + "dev": "VITE_APP_API_TEST=true vite build --watch", + "dev:tw-watch": "bash scripts/tw-watch.sh" }, "dependencies": { "@dappnode/common": "workspace:^0.1.0", @@ -23,6 +24,7 @@ "@grafana/faro-web-sdk": "^2.3.1", "@grafana/faro-web-tracing": "^2.3.1", "@reduxjs/toolkit": "^1.3.5", + "@tailwindcss/vite": "^4.2.1", "@types/clipboard": "^2.0.7", "@types/qrcode.react": "^1.0.2", "@types/react": "^18.2.14", @@ -34,14 +36,20 @@ "@vitejs/plugin-react": "^4.3.1", "ajv": "^6.10.2", "bootstrap": "^5.3", + "class-variance-authority": "^0.7.1", "clipboard": "^2.0.1", + "clsx": "^2.1.1", "deepmerge": "^2.1.1", + "embla-carousel-react": "^8.6.0", "ethereum-blockies-base64": "^1.0.2", "is-ipfs": "^8.0.1", "lodash-es": "^4.17.21", + "lucide-react": "^0.577.0", "mitt": "^2.1.0", + "next-themes": "^0.4.6", "pretty-bytes": "^5.3.0", "qrcode.react": "^0.8.0", + "radix-ui": "^1.4.3", "react": "^18.2.0", "react-bootstrap": "^2.10", "react-dom": "^18.3.1", @@ -55,9 +63,15 @@ "redux-thunk": "^2.3.0", "sass": "^1.49.7", "semver": "^7.3.8", + "shadcn": "^4.0.8", "socket.io-client": "^4.5.1", + "sonner": "^2.0.7", "styled-components": "^4.2.0", "swr": "^0.2.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", "vite": "^5.4.19", "vite-tsconfig-paths": "^4.3.2" }, diff --git a/packages/admin-ui/scripts/tw-watch.sh b/packages/admin-ui/scripts/tw-watch.sh new file mode 100755 index 0000000000..2038728f6f --- /dev/null +++ b/packages/admin-ui/scripts/tw-watch.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Watches for .tsx/.ts file changes under src/ and touches tailwind.css +# to trigger a Tailwind CSS rebuild via vite build --watch. +# +# This is needed because vite build --watch (Rollup) doesn't re-process +# tailwind.css when only .tsx files change — Tailwind needs to re-scan +# for new tw: classes. +# +# Excludes the styles/ directory to avoid infinite loops (touching +# tailwind.css would otherwise re-trigger the watcher). + +cd "$(dirname "$0")/.." || exit 1 + +echo "[tw-watch] Watching src/ for .tsx/.ts changes..." + +while inotifywait -r -e modify --exclude 'styles/' src/; do + echo "[tw-watch] Change detected, touching tailwind.css" + touch src/styles/tailwind.css +done diff --git a/packages/admin-ui/src/App.tsx b/packages/admin-ui/src/App.tsx index 28e2a9d1de..a7aab2293b 100644 --- a/packages/admin-ui/src/App.tsx +++ b/packages/admin-ui/src/App.tsx @@ -1,24 +1,30 @@ import React, { useCallback, useEffect, useState } from "react"; -import { Route, useLocation, useNavigate } from "react-router-dom"; +import { Route, useLocation, Navigate } from "react-router-dom"; import { startApi, apiAuth, LoginStatus } from "api"; // Components -import { ToastContainer } from "react-toastify"; -import NotificationsMain from "./components/NotificationsMain"; import ErrorBoundary from "./components/ErrorBoundary"; import Loading from "components/Loading"; -import Welcome from "components/welcome/Welcome"; -import SideBar from "components/sidebar/SideBar"; -import { TopBar } from "components/topbar/TopBar"; -// Pages +// Theme +import { ThemeProvider, useTheme } from "components/ThemeProvider"; +// Legacy pages import { pages } from "./pages"; -import { Login } from "./start-pages/Login"; -import { Register } from "./start-pages/Register"; -import { NoConnection } from "start-pages/NoConnection"; +// New pages +// import { NewHomePage } from "./pages-new/home/HomePage"; + +// Old start pages (keep until deleted) +// import { Login } from "./start-pages/Login"; +// import { Register } from "./start-pages/Register"; +// import { NoConnection } from "start-pages/NoConnection"; +import { LoginPage } from "./pages-new/home/LoginPage"; +import { RegisterPage } from "./pages-new/home/RegisterPage"; +import { NoConnectionPage } from "./pages-new/home/NoConnectionPage"; +import { AiLayout } from "./pages-new/ai/AiLayout"; +import { HomeLayout } from "./pages-new/home/HomeLayout"; +import { StakingLayout } from "./pages-new/staking/StakingLayout"; +// Layouts +import { LegacyStakingLayout } from "./layouts/LegacyStakingLayout"; // Types -import { AppContextIface, Theme } from "types"; -import Smooth from "components/Smooth"; -import { PwaPermissionsAlert, PwaPermissionsModal } from "components/PwaPermissions"; -import { LocalProxyBanner } from "pages/wifi/components/localProxying/LocalProxyBanner"; +import { AppContextIface } from "types"; // Grafana Faro for frontend monitoring and tracing import { FaroRoutes } from "@grafana/faro-react"; import { useUiTelemetryConsent } from "hooks/useUiTelemetryConsent"; @@ -28,28 +34,6 @@ export const AppContext = React.createContext({ toggleTheme: () => {} }); -const useLocalStorage = ( - key: string, - initialValue: T -): [T, React.Dispatch>] => { - const [storedValue, setStoredValue] = useState(() => { - try { - const item = window.localStorage.getItem(key); - // Assert that either the item or initialValue is of type T - return (item as T) || initialValue; - } catch (error) { - console.error(error); - return initialValue; - } - }); - - useEffect(() => { - window.localStorage.setItem(key, storedValue); - }, [key, storedValue]); - - return [storedValue, setStoredValue]; -}; - function MainApp({ username }: { username: string }) { // App is the parent container of any other component. // If this re-renders, the whole app will. So DON'T RERENDER APP! @@ -59,7 +43,11 @@ function MainApp({ username }: { username: string }) { useUiTelemetryConsent(); const [screenWidth, setScreenWidth] = useState(window.innerWidth); - const [theme, setTheme] = useLocalStorage("theme", "light"); + const { theme, setTheme } = useTheme(); + + // Resolve "system" to actual light/dark for legacy code that expects a binary value + const resolvedTheme = + theme === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : theme; useEffect(() => { const handleResize = () => setScreenWidth(window.innerWidth); @@ -73,35 +61,38 @@ function MainApp({ username }: { username: string }) { window.scrollTo(0, 0); }, [screenLocation.pathname]); + // Legacy AppContext — derives from the ThemeProvider const appContext: AppContextIface = { - theme, - toggleTheme: () => setTheme((curr: Theme) => (curr === "light" ? "dark" : "light")) + theme: resolvedTheme, + toggleTheme: () => setTheme(resolvedTheme === "light" ? "dark" : "light") }; + // Keep class in sync for legacy CSS that targets body.dark / body.light useEffect(() => { - const html = document.documentElement; const body = document.body; - - html.classList.remove("light", "dark"); body.classList.remove("light", "dark"); - - html.classList.add(theme); - body.classList.add(theme); - }, [theme]); + body.classList.add(resolvedTheme); + }, [resolvedTheme]); return ( -
- - -
- - - - - - - {/** Provide the app context only to the dashboard (where the modules switch is handled) */} +
+ + {/* New UI routes — Tailwind + shadcn, no legacy chrome */} + + + + } + /> + + {/* Legacy routes — Bootstrap + SCSS, with sidebar/topbar/legacy chrome */} + } + > {Object.values(pages).map(({ RootComponent, rootPath }) => ( ))} - {/* Redirection for routes with hashes */} - {/* 404 routes redirect to dashboard or default page */} - } /> - -
- - {/* Place here non-page components */} - - - - + {/* Default: redirect /legacy to /legacy/dashboard */} + } /> + + + {/* Staking section — new Tailwind + shadcn pages */} + + + + } + /> + + {/* Home section — catch-all for / and /info, /settings etc. */} + + + + } + /> +
); } -function DefaultRedirect() { - const navigate = useNavigate(); - const location = useLocation(); - - useEffect(() => { - if (location.pathname === "/") { - navigate("/dashboard", { replace: true }); - } - }, [location, navigate]); - - return null; +export default function App() { + return ( + + + + ); } -export default function App() { +function AppInner() { const [loginStatus, setLoginStatus] = useState(); // Handles the login, register and connecting logic. Nothing else will render // Until the app has been logged in @@ -188,12 +187,16 @@ export default function App() { case "logged-in": return ; case "not-logged-in": - return ; + // return ; + return ; case "not-registered": - return ; + // return ; + return ; case "error": - return ; + // return ; + return ; default: - return ; + // return ; + return ; } } diff --git a/packages/admin-ui/src/components/ConfirmDialog.tsx b/packages/admin-ui/src/components/ConfirmDialog.tsx index 7987056b88..39c08885f8 100644 --- a/packages/admin-ui/src/components/ConfirmDialog.tsx +++ b/packages/admin-ui/src/components/ConfirmDialog.tsx @@ -65,7 +65,7 @@ function Modal({ return (
{ if (modalEl.current === e.target) onClose(); diff --git a/packages/admin-ui/src/components/ThemeProvider.tsx b/packages/admin-ui/src/components/ThemeProvider.tsx new file mode 100644 index 0000000000..2bca8e5e02 --- /dev/null +++ b/packages/admin-ui/src/components/ThemeProvider.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "dappnode-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + } + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/packages/admin-ui/src/components/ThemeToggle.tsx b/packages/admin-ui/src/components/ThemeToggle.tsx new file mode 100644 index 0000000000..49f02bd362 --- /dev/null +++ b/packages/admin-ui/src/components/ThemeToggle.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Sun, Moon } from "lucide-react"; +import { Button } from "components/primitives/button"; +import { useTheme } from "components/ThemeProvider"; +import { cn } from "lib/utils"; + +/** + * A small icon button that toggles between light and dark mode. + * + * Uses the app-wide ThemeProvider so the choice persists across reloads + * and is consistent across all pages (new + legacy). + * + * The Sun/Moon icons crossfade using Tailwind's dark: variant — only the + * relevant icon is visible at any given time. + */ +export function ThemeToggle({ className }: { className?: string }) { + const { setTheme, theme } = useTheme(); + + const toggleTheme = () => { + setTheme(theme === "dark" ? "light" : "dark"); + }; + + return ( + + ); +} diff --git a/packages/admin-ui/src/components/primitives/accordion.tsx b/packages/admin-ui/src/components/primitives/accordion.tsx new file mode 100644 index 0000000000..ec13fdf454 --- /dev/null +++ b/packages/admin-ui/src/components/primitives/accordion.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import { Accordion as AccordionPrimitive } from "radix-ui"; + +import { cn } from "lib/utils"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +function Accordion({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ className, children, ...props }: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/admin-ui/src/components/primitives/alert-dialog.tsx b/packages/admin-ui/src/components/primitives/alert-dialog.tsx new file mode 100644 index 0000000000..df53a731eb --- /dev/null +++ b/packages/admin-ui/src/components/primitives/alert-dialog.tsx @@ -0,0 +1,164 @@ +import * as React from "react"; +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; + +import { cn } from "lib/utils"; +import { Button } from "components/primitives/button"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger +}; diff --git a/packages/admin-ui/src/components/primitives/alert.tsx b/packages/admin-ui/src/components/primitives/alert.tsx new file mode 100644 index 0000000000..3e09da2221 --- /dev/null +++ b/packages/admin-ui/src/components/primitives/alert.tsx @@ -0,0 +1,77 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "lib/utils" + +const alertVariants = cva( + "tw:group/alert tw:relative tw:grid tw:w-full tw:gap-0.5 tw:rounded-lg tw:border tw:px-2.5 tw:py-2 tw:text-left tw:text-sm tw:has-data-[slot=alert-action]:relative tw:has-data-[slot=alert-action]:pr-18 tw:has-[>svg]:grid-cols-[auto_1fr] tw:has-[>svg]:gap-x-2 tw:*:[svg]:row-span-2 tw:*:[svg]:translate-y-0.5 tw:*:[svg]:text-current tw:*:[svg:not([class*=size-])]:size-4", + { + variants: { + variant: { + default: "tw:bg-card tw:text-card-foreground", + warning: "tw:bg-card tw:text-caution tw:*:data-[slot=alert-description]:text-caution/90 tw:*:[svg]:text-current", + destructive: + "tw:bg-card tw:text-destructive tw:*:data-[slot=alert-description]:text-destructive/90 tw:*:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 tw:[&_a]:underline tw:[&_a]:underline-offset-3 tw:[&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/packages/admin-ui/src/components/primitives/badge.tsx b/packages/admin-ui/src/components/primitives/badge.tsx new file mode 100644 index 0000000000..ee9c4efb14 --- /dev/null +++ b/packages/admin-ui/src/components/primitives/badge.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "lib/utils" + +const badgeVariants = cva( + "tw:group/badge tw:inline-flex tw:h-5 tw:w-fit tw:shrink-0 tw:items-center tw:justify-center tw:gap-1 tw:overflow-hidden tw:rounded-4xl tw:border tw:border-transparent tw:px-2 tw:py-0.5 tw:text-xs tw:font-medium tw:whitespace-nowrap tw:transition-all tw:focus-visible:border-ring tw:focus-visible:ring-[3px] tw:focus-visible:ring-ring/50 tw:has-data-[icon=inline-end]:pr-1.5 tw:has-data-[icon=inline-start]:pl-1.5 tw:aria-invalid:border-destructive tw:aria-invalid:ring-destructive/20 tw:dark:aria-invalid:ring-destructive/40 tw:[&>svg]:pointer-events-none tw:[&>svg]:size-3!", + { + variants: { + variant: { + default: "tw:bg-primary tw:text-primary-foreground tw:[a]:hover:bg-primary/80", + secondary: + "tw:bg-secondary tw:text-secondary-foreground tw:[a]:hover:bg-secondary/80", + destructive: + "tw:bg-destructive/10 tw:text-destructive tw:focus-visible:ring-destructive/20 tw:dark:bg-destructive/20 tw:dark:focus-visible:ring-destructive/40 tw:[a]:hover:bg-destructive/20", + success: + "tw:bg-success/10 tw:text-success tw:focus-visible:ring-success/20 tw:dark:bg-success/20 tw:dark:focus-visible:ring-success/40 tw:[a]:hover:bg-success/20", + caution: + "tw:bg-caution/10 tw:text-caution tw:focus-visible:ring-caution/20 tw:dark:bg-caution/20 tw:dark:focus-visible:ring-caution/40 tw:[a]:hover:bg-caution/20", + outline: + "tw:border-border tw:text-foreground tw:[a]:hover:bg-muted tw:[a]:hover:text-muted-foreground", + ghost: + "tw:hover:bg-muted tw:hover:text-muted-foreground tw:dark:hover:bg-muted/50", + link: "tw:text-primary tw:underline-offset-4 tw:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = (asChild ? Slot.Root : "span") as React.ElementType + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/packages/admin-ui/src/components/primitives/breadcrumb.tsx b/packages/admin-ui/src/components/primitives/breadcrumb.tsx new file mode 100644 index 0000000000..84bf166e70 --- /dev/null +++ b/packages/admin-ui/src/components/primitives/breadcrumb.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import { Slot } from "radix-ui"; + +import { cn } from "lib/utils"; +import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"; + +function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { + return