diff --git a/.gitignore b/.gitignore index c28f963..9540bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,11 @@ build/ ### Mac OS ### .DS_Store -## Directories created by Namazu Elemetns +## Directories created by Namazu Elements /cdn-repos/** /script-repos/** +## Frontend build tooling +/ui/node_modules/ +/ui/.node/ + diff --git a/.idea/encodings.xml b/.idea/encodings.xml index 146497a..f2aca1b 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -9,5 +9,7 @@ + + \ No newline at end of file diff --git a/.idea/runConfigurations/build_ui.xml b/.idea/runConfigurations/build_ui.xml new file mode 100644 index 0000000..6ac303f --- /dev/null +++ b/.idea/runConfigurations/build_ui.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ui/src/superuser/plugin-entry.ts b/ui/src/superuser/plugin-entry.ts new file mode 100644 index 0000000..a112c44 --- /dev/null +++ b/ui/src/superuser/plugin-entry.ts @@ -0,0 +1,9 @@ +import { ExamplePlugin } from './ExamplePlugin' + +declare const window: Window & { + __elementsPlugins?: { + register(route: string, component: unknown): void + } +} + +window.__elementsPlugins?.register('example-element', ExamplePlugin) diff --git a/ui/src/user/ExamplePlugin.tsx b/ui/src/user/ExamplePlugin.tsx new file mode 100644 index 0000000..69794d3 --- /dev/null +++ b/ui/src/user/ExamplePlugin.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export function ExamplePlugin() { + return ( +
+

Example Element

+

+ This page is served from the Example Element’s user UI content directory. +

+
+ Installed Elements can inject custom user-facing UI by placing a plugin.json + and plugin.bundle.js in their ui/user/ content directory. +
+
+ ) +} diff --git a/ui/src/user/dev-entry.tsx b/ui/src/user/dev-entry.tsx new file mode 100644 index 0000000..44eb1ef --- /dev/null +++ b/ui/src/user/dev-entry.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { ExamplePlugin } from './ExamplePlugin' +import '../dev.css' + +function DevShell() { + const [dark, setDark] = React.useState( + () => window.matchMedia('(prefers-color-scheme: dark)').matches + ) + + React.useEffect(() => { + document.documentElement.classList.toggle('dark', dark) + }, [dark]) + + return ( +
+ {import.meta.env.DEV && ( +
+ +
+ )} + +
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/ui/src/user/index.html b/ui/src/user/index.html new file mode 100644 index 0000000..5abfbee --- /dev/null +++ b/ui/src/user/index.html @@ -0,0 +1,12 @@ + + + + + + Example Element — User Plugin (dev) + + +
+ + + diff --git a/ui/src/user/plugin-entry.ts b/ui/src/user/plugin-entry.ts new file mode 100644 index 0000000..a112c44 --- /dev/null +++ b/ui/src/user/plugin-entry.ts @@ -0,0 +1,9 @@ +import { ExamplePlugin } from './ExamplePlugin' + +declare const window: Window & { + __elementsPlugins?: { + register(route: string, component: unknown): void + } +} + +window.__elementsPlugins?.register('example-element', ExamplePlugin) diff --git a/ui/tailwind.config.ts b/ui/tailwind.config.ts new file mode 100644 index 0000000..5d17e6a --- /dev/null +++ b/ui/tailwind.config.ts @@ -0,0 +1,79 @@ +import type { Config } from "tailwindcss"; + +export default { + darkMode: ["class"], + content: ["./src/**/*.{ts,tsx}"], + theme: { + extend: { + borderRadius: { + lg: ".5625rem", + md: ".375rem", + sm: ".1875rem", + }, + colors: { + background: "hsl(var(--background) / )", + foreground: "hsl(var(--foreground) / )", + border: "hsl(var(--border) / )", + input: "hsl(var(--input) / )", + card: { + DEFAULT: "hsl(var(--card) / )", + foreground: "hsl(var(--card-foreground) / )", + border: "hsl(var(--card-border) / )", + }, + popover: { + DEFAULT: "hsl(var(--popover) / )", + foreground: "hsl(var(--popover-foreground) / )", + border: "hsl(var(--popover-border) / )", + }, + primary: { + DEFAULT: "hsl(var(--primary) / )", + foreground: "hsl(var(--primary-foreground) / )", + border: "var(--primary-border)", + }, + secondary: { + DEFAULT: "hsl(var(--secondary) / )", + foreground: "hsl(var(--secondary-foreground) / )", + border: "var(--secondary-border)", + }, + muted: { + DEFAULT: "hsl(var(--muted) / )", + foreground: "hsl(var(--muted-foreground) / )", + border: "var(--muted-border)", + }, + accent: { + DEFAULT: "hsl(var(--accent) / )", + foreground: "hsl(var(--accent-foreground) / )", + border: "var(--accent-border)", + }, + destructive: { + DEFAULT: "hsl(var(--destructive) / )", + foreground: "hsl(var(--destructive-foreground) / )", + border: "var(--destructive-border)", + }, + ring: "hsl(var(--ring) / )", + sidebar: { + ring: "hsl(var(--sidebar-ring) / )", + DEFAULT: "hsl(var(--sidebar) / )", + foreground: "hsl(var(--sidebar-foreground) / )", + border: "hsl(var(--sidebar-border) / )", + }, + "sidebar-primary": { + DEFAULT: "hsl(var(--sidebar-primary) / )", + foreground: "hsl(var(--sidebar-primary-foreground) / )", + border: "var(--sidebar-primary-border)", + }, + "sidebar-accent": { + DEFAULT: "hsl(var(--sidebar-accent) / )", + foreground: "hsl(var(--sidebar-accent-foreground) / )", + border: "var(--sidebar-accent-border)", + }, + }, + fontFamily: { + sans: ["var(--font-sans)"], + serif: ["var(--font-serif)"], + mono: ["var(--font-mono)"], + }, + }, + }, + plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], +} satisfies Config; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..c5a1f70 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react", + "jsxFactory": "React.createElement", + "jsxFragmentFactory": "React.Fragment", + "strict": true, + "skipLibCheck": true + }, + "include": ["src", "vite.base.config.ts", "vite.superuser.config.ts", "vite.user.config.ts", "tailwind.config.ts", "postcss.config.ts"] +} diff --git a/ui/vite.base.config.ts b/ui/vite.base.config.ts new file mode 100644 index 0000000..e434a7f --- /dev/null +++ b/ui/vite.base.config.ts @@ -0,0 +1,56 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export function createConfig(segment: string) { + return defineConfig(({ command }) => { + if (command === 'serve') { + // Standalone dev server — renders the component in isolation with HMR. + // Run via: npm run dev:superuser or npm run dev:user + // + // API calls are proxied to the running Elements instance so that relative + // paths like /api/rest/version resolve correctly regardless of the dev + // server port. Override the target with the ELEMENTS_URL env var: + // ELEMENTS_URL=http://localhost:9090 npm run dev:superuser + const elementsUrl = process.env.ELEMENTS_URL ?? 'http://localhost:8080' + return { + plugins: [react({ jsxRuntime: 'classic' })], + root: `src/${segment}`, + server: { + proxy: { + '/api': elementsUrl, + '/app': elementsUrl, + }, + }, + } + } + + // Library/IIFE build — writes plugin.bundle.js directly into + // ../element/src/main/ui/{segment}/ for packaging into the .elm artifact. + // Run via: npm run build + return { + esbuild: { + jsx: 'transform', + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + }, + build: { + lib: { + entry: `src/${segment}/plugin-entry.ts`, + name: 'ElementPlugin', + formats: ['iife' as const], + fileName: () => 'plugin.bundle.js', + }, + outDir: `../element/src/main/ui/${segment}`, + emptyOutDir: false, + minify: false, + rollupOptions: { + external: ['react'], + output: { + // Rewrites `import React from 'react'` → `var React = window.React` in the IIFE. + globals: { react: 'window.React' }, + }, + }, + }, + } + }) +} diff --git a/ui/vite.superuser.config.ts b/ui/vite.superuser.config.ts new file mode 100644 index 0000000..3bea348 --- /dev/null +++ b/ui/vite.superuser.config.ts @@ -0,0 +1,2 @@ +import { createConfig } from './vite.base.config' +export default createConfig('superuser') diff --git a/ui/vite.user.config.ts b/ui/vite.user.config.ts new file mode 100644 index 0000000..6417df2 --- /dev/null +++ b/ui/vite.user.config.ts @@ -0,0 +1,2 @@ +import { createConfig } from './vite.base.config' +export default createConfig('user')