feat: complete UI/UX overhaul with Impeccable design system

Replace Inter/Baskerville with self-hosted Manrope/Alegreya/JetBrains Mono
variable fonts. Migrate all colors from hex to OKLCH tokens (30+ CSS custom
properties) with full dark/light mode support. Replace Unicode emoji with
lucide-react SVG icons throughout. Convert all page layouts to inline styles
(Tailwind CSS 4 flex/grid classes unreliable in this project). Code-split
routes via React.lazy (760KB → 15KB initial shell + 10 lazy chunks).

Add global styles: scrollbar theming, selection color, input/button bases,
:focus-visible ring, prefers-reduced-motion. Setup wizard gets 4-phase
indicator with numbered circles, PathInput and StatusDot components.
Toast container gets aria-live="polite". Tab close buttons changed to
proper <button> elements with aria-labels.

All 8 pages (Dashboard, Editor, Build, Server, Toolset, Repos, Settings,
Setup) rewritten with consistent card/section/button patterns.
This commit is contained in:
plenarius
2026-04-21 03:06:29 -04:00
parent 8b35c41a52
commit cbe51a6e67
29 changed files with 3531 additions and 1206 deletions
+30
View File
@@ -0,0 +1,30 @@
## Design Context
### Users
Layonara community contributors — NWScript coders, area builders, item designers, and DMs who maintain a 20-year-old Neverwinter Nights persistent world. They range from experienced developers to hobbyists learning scripting. Usage patterns vary: late-night hobby tinkering, focused multi-hour work sessions, and quick surgical edits. The tool runs locally in a browser via Docker — always localhost, never a hosted SaaS.
### Brand Personality
**Arcane, precise, deep.**
The Forge is an enchanter's workbench — not flashy magic, but the quiet kind. Tools laid out with intention, everything in its place. It respects the craft and the people who show up to do it. Warm but serious. Competent without being cold.
### Aesthetic Direction
- **Tone:** A craftsman's workshop that happens to be digital. Dark, warm, information-dense but never cluttered. The warmth comes from material choices (amber/gold tones, warm neutrals) not from rounded corners and playful colors.
- **Primary theme:** Dark. Light mode exists as a secondary option but is not the priority.
- **Accent color:** Evolve the current gold (#946200) — keep the amber/forge warmth but refine it. Richer, more intentional. Consider a warm amber in OKLCH that reads well on dark surfaces without looking muddy.
- **Reference:** VS Code's layout patterns (sidebar, tabs, panels, terminal) are the right structural model. Users will feel at home. But the soul should feel purpose-built for Neverwinter Nights, not generic.
- **Anti-references:** Generic SaaS dashboards (white cards, blue primary buttons). Gamer aesthetic (neon, RGB, aggressive angles). Toy-like UI (bubbly, round, playful). Retro/pixel nostalgia. Corporate enterprise gray. AI slop (purple gradients, Inter font, cards nested in cards, bounce animations).
### Design Principles
1. **Tools, not decoration.** Every element earns its space. No ornamental cards, no hero metrics, no dashboard widgets that exist to fill a grid. If it doesn't help someone build a module, it doesn't belong.
2. **Warm darks, not cold ones.** Tint surfaces toward the brand amber. Pure gray and pure black are banned. The dark theme should feel like firelight on stone, not a terminal at 3am.
3. **Density with breathing room.** IDE users expect information density. Give it to them, but use varied spacing to create rhythm and hierarchy. Tight where things are related, generous where sections change.
4. **Familiar structure, distinctive character.** VS Code conventions for navigation and layout. But the typography, color, and details should make it unmistakably Layonara Forge — not another Electron app clone.
5. **Craft the details.** Focus states, transitions, hover treatments, scrollbar styling, selection colors — these are where "functional but ugly" becomes "someone cared about this."
### Technical Constraints
- React 19 + Vite 6 + Tailwind CSS 4 (no component library — custom components throughout)
- Monaco Editor (brings its own theming system, must integrate with app tokens)
- xterm.js terminal (needs theme integration)
- Must work in Chrome/Firefox/Edge on desktop. No mobile requirement.
- No Google Fonts dependency for body text preferred — the app runs on localhost, offline-capable is a plus.
+452
View File
@@ -0,0 +1,452 @@
# Layonara Forge — Agent Handoff Document
## What Is This
Layonara Forge is a purpose-built NWN (Neverwinter Nights) Development IDE that runs as a local web application in Docker. It lets contributors fork, clone, edit, build, and run a complete Layonara NWNX NWN server with zero native tooling — only Docker required. The project name was chosen during brainstorming: "Forge" evokes crafting/building in a fantasy context.
## Project Location
- **Forge codebase**: `/home/jmg/dev/layonara/layonara-forge/`
- **Design spec**: `/home/jmg/dev/docs/superpowers/specs/2026-04-20-layonara-forge-design.md`
- **Implementation plans**: `/home/jmg/dev/docs/superpowers/plans/2026-04-20-forge-plan-{1..6}-*.md`
- **Gitea integration plan**: See Cursor plan file `gitea_+_forge_integration_8e0df077.plan.md`
- **Design context**: `.impeccable.md` at project root (personality: arcane, precise, deep)
## Architecture
```
Host Machine
├── Browser → localhost:3000 (Forge UI)
├── NWN Toolset → writes GFFs to modules/temp0/
├── ~/layonara-workspace/ (repos, server data, config)
└── Docker Socket
Docker Network
├── layonara-forge (Node.js + React, serves UI, manages everything via Docker socket)
├── layonara-builder (ephemeral: nasher, nwn_script_comp, layonara_nwn, cmake, gcc)
├── layonara-nwserver (ghcr.io/plenarius/unified — NWN:EE + NWNX)
└── layonara-mariadb (mariadb:10.11 — game database)
```
The Forge container manages sibling containers via the Docker socket (Portainer pattern). Contributors clone one repo, set two paths in `.env`, run `docker compose up`, and open a browser.
## Tech Stack
- **Backend**: Node.js 20 + Express 5 + TypeScript (ES modules)
- **Frontend**: React 19 + Vite 6 + Tailwind CSS 4 (utility classes unreliable — inline styles used for layout)
- **Icons**: lucide-react (SVG icons throughout)
- **Fonts**: Self-hosted variable fonts via @fontsource-variable (Manrope, Alegreya, JetBrains Mono)
- **Code editor**: Monaco Editor with NWScript Monarch tokenizer
- **NWScript LSP**: Forked `layonara/nwscript-ee-language-server` connected via WebSocket JSON-RPC
- **Terminal**: xterm.js with child_process.spawn shell sessions
- **Docker API**: dockerode
- **Git**: simple-git (named import: `import { simpleGit } from "simple-git"`)
- **Git provider**: Gitea at `https://gitea.layonara.com` (NOT GitHub — see Gitea section)
- **Real-time**: WebSocket via ws library
## What's Built (55 commits + UI overhaul session)
All 6 implementation plans are complete. The codebase compiles and both Docker images build successfully. A complete UI/UX overhaul was performed (see "UI/UX Overhaul" section below).
### Backend Services (`packages/backend/src/services/`)
| Service | File | Purpose |
| ------------ | ------------------------- | ------------------------------------------------------------------ |
| WebSocket | `ws.service.ts` | Event broadcasting to all connected clients |
| Workspace | `workspace.service.ts` | Directory structure management, forge.json config |
| Docker | `docker.service.ts` | Container CRUD, image pulls, ephemeral container runs |
| Build | `build.service.ts` | Module compile/pack, hot-reload, hak builds, NWNX builds |
| Server | `server.service.ts` | NWN server stack lifecycle (start/stop/restart MariaDB + nwserver) |
| Toolset | `toolset.service.ts` | temp0/ file watcher, GFF→JSON conversion, change management |
| Editor | `editor.service.ts` | File CRUD, directory trees, workspace search |
| Git | `git.service.ts` | Clone, pull, commit, push, diff, upstream polling |
| Git Provider | `git-provider.service.ts` | Gitea API (fork, PR, token validation) |
| Terminal | `terminal.service.ts` | Shell session management via child_process |
| LSP | `lsp.service.ts` | NWScript language server process management |
### Backend Routes (`packages/backend/src/routes/`)
| Route | Prefix | Endpoints |
| --------- | ---------------- | ----------------------------------------------------------------- |
| workspace | `/api/workspace` | GET /config, PUT /config, POST /init |
| docker | `/api/docker` | containers, pull, start/stop/restart, logs |
| build | `/api/build` | module/compile, module/pack, deploy, compile-single, haks, nwnx |
| server | `/api/server` | status, start, stop, restart, generate-config, seed-db, sql |
| toolset | `/api/toolset` | status, start, stop, changes, apply, apply-all, discard |
| editor | `/api/editor` | tree, file CRUD, search, resref, tlk, 2da, gff-schema |
| github | `/api/github` | validate-pat (actually Gitea token), fork, forks, pr, prs |
| repos | `/api/repos` | clone, list (/), status (/:repo/status), pull, commit, push, diff |
| terminal | `/api/terminal` | sessions CRUD |
### Frontend Pages (`packages/frontend/src/pages/`)
| Page | Route | Purpose |
| --------- | ----------- | ----------------------------------------------------------- |
| Dashboard | `/` | Server status, repo summary, quick actions (3-column cards) |
| Editor | `/editor` | Monaco editor with file explorer, tabs, GFF visual editors |
| Build | `/build` | Module/hak/NWNX build sections with streaming output |
| Server | `/server` | Controls, log viewer with filter, SQL console |
| Toolset | `/toolset` | temp0/ watcher status, change table, diff viewer |
| Repos | `/repos` | Git status cards, commit dialog, PR creation |
| Settings | `/settings` | PAT, theme, editable paths, Docker images, shortcuts, reset |
| Setup | `/setup` | 4-phase onboarding wizard with 10 steps |
### Special Features
- **NWScript syntax highlighting**: Monarch tokenizer with keyword/type/comment/string/preprocessor rules
- **SQL highlighting in NWScript strings**: Detects `NWNX_SQL_PrepareQuery()` calls, highlights SQL keywords in teal
- **Resref auto-lookup**: Backend indexes all GFF JSON files, hover on resref strings shows the item/creature/area
- **TLK preview**: Hover on integer literals shows the TLK string (handles 16777216 custom offset)
- **2DA intellisense**: Parses 2da files, provides completion for `Get2DAString` calls
- **Visual GFF editors**: Form-based editors for .uti, .utc, .are, .dlg, .utp, .utm JSON files
- **Conventional commit enforcement**: Type dropdown (feat/fix/refactor/etc), rejects malformed messages
- **Dark/light theme**: OKLCH CSS custom properties toggled via `light` class on root element
## UI/UX Overhaul (April 21, 2026 session)
A complete design overhaul was performed using the [Impeccable](https://impeccable.style/) design skill system. The design context is documented in `.impeccable.md`.
### Design System
**Personality**: Arcane, precise, deep — a craftsman's workbench.
**Fonts** (all self-hosted via `@fontsource-variable`, no Google Fonts):
- **Body/UI**: Manrope Variable — warm geometric sans
- **Headings**: Alegreya Variable — calligraphic serif with manuscript roots
- **Code/mono**: JetBrains Mono Variable
**Color palette** (full OKLCH, 30+ tokens in `globals.css`):
- Surfaces tinted toward amber (hue 65) — "warm darks, not cold ones"
- 3-level depth: `--forge-bg``--forge-surface``--forge-surface-raised`
- Accent: evolved gold `oklch(58% 0.155 65)` with hover and subtle variants
- Semantic colors: success (forest green, hue 150), danger (brick red, hue 25), warning (golden, hue 80), info (steel blue, hue 230)
- Each semantic color has base, bg, and border variants for both dark and light modes
- Log panels: dedicated `--forge-log-bg` / `--forge-log-text` tokens
**Icons**: lucide-react SVG icons throughout (Code2, Wrench, Hammer, Play, GitBranch, Settings, Sun/Moon, Terminal, etc.)
**Type scale** (fixed rem for IDE density):
- `--text-xs` (11px) through `--text-2xl` (28px), ~1.25 ratio
### What Changed
**Foundation**:
- Replaced Inter font with Manrope Variable, Baskerville with Alegreya Variable
- Removed Google Fonts `<link>` — all fonts bundled as npm deps
- Full OKLCH palette replacing all hex values (~60 hard-coded colors replaced)
- All Tailwind semantic color classes (`green-400`, `red-500/20`, etc.) replaced with forge tokens
- Global CSS: scrollbar theming, selection color, input/button base styles, `:focus-visible` ring, `prefers-reduced-motion`
**IDE Shell** (`IDELayout.tsx`):
- Lucide SVG icons replacing Unicode emoji in nav rail
- Removed 3px left border stripe (impeccable anti-pattern ban)
- Sidebar only shows on `/editor` route (was showing on all pages)
- All layout uses inline styles (Tailwind flex classes were not reliably applying)
- Terminal toggle bar with Terminal/Chevron icons
**All 8 pages rewritten** with consistent patterns:
- Card containers: `--forge-surface` bg, `--forge-border`, `0.75rem` radius
- Section headers: uppercase, `--text-xs`, icon + label
- Buttons: accent primary, outline secondary, danger for destructive
- Status badges: semantic colors with dots
- All inline styles (Tailwind utility classes unreliable for layout in this project)
**Setup wizard**:
- 4-phase indicator (Environment → Authentication → Repositories → Finalize) with numbered circles + connecting lines, matching James's work app wizard pattern
- Steps reordered: Workspace + NWN Home before Gitea Token
- PathInput component with folder icon for path fields
- StatusDot component replacing emoji (✅❌⏳) with styled HTML elements
- Navigation: ghost "← Back" left, accent "Next →" right, border-top separator
**Performance**:
- Routes code-split via `React.lazy()` — 10 chunks instead of 1 (760KB → initial 15KB app shell)
- Page chunks: Editor 98KB, Setup 16KB, Repos 13KB, others 5-8KB each
**Accessibility**:
- `:focus-visible` outline on all interactive elements
- `aria-label="Main navigation"` on nav
- Tab close button changed from `<span>` to `<button aria-label="Close tab">`
- Toast container has `aria-live="polite"` + `role="status"`
- `window.confirm()` guards on destructive actions (Discard All, Reset Setup)
- SetupGuard shows "Loading Forge…" instead of blank screen
### Important: Tailwind CSS 4 Quirk
Tailwind CSS 4 utility classes for layout (`flex`, `flex-1`, `items-center`, etc.) do NOT reliably apply in this project. All critical layout uses **inline styles** instead. This is a conscious decision, not laziness. The Tailwind `@import "tailwindcss"` is still loaded and works for some utilities (`rounded`, `overflow-hidden`, etc.) but **do not rely on Tailwind classes for flex/grid layout**. Use inline `style={{}}` props.
## Docker Images
### layonara-forge (563MB)
- Base: `node:20-slim`
- Multi-stage build: builder stage compiles TS + Vite, production stage has only runtime deps
- Serves React frontend as static files from Express
- The Dockerfile is at repo root: `Dockerfile`
### layonara-builder (577MB)
- Base: `ubuntu:24.04`
- All tools installed from **pre-built GitHub Release binaries** (no Nim compilation)
- The Dockerfile is at `builder/Dockerfile`
- Tools: nwn_gff, nwn_script_comp, nasher, layonara_nwn, cmake, gcc, git
**Critical**: The builder Dockerfile downloads pre-built binaries from:
- `layonara/neverwinter.nim` releases (nwn_gff, nwn_script_comp, etc.)
- `squattingmonk/nasher.nim` releases (nasher)
- `plenarius/layonara_nwn` releases (layonara_nwn)
If any of these release URLs break, the builder image won't build.
## Gitea Infrastructure
GitHub is no longer the primary git provider for contributors. Gitea is self-hosted on xandrial.
### Setup
- **Gitea URL**: `https://gitea.layonara.com`
- **Host**: xandrial (159.69.30.129, Hetzner CPX41)
- **Managed by**: Coolify on leanthar, service UUID `xo2yy8rml79lkzmf92cgeory`
- **Database**: PostgreSQL 16 sidecar in same Coolify service
- **Auth**: Authentik OIDC SSO (same login as Nextcloud and email)
- **SSH**: Port 2222 for git-over-SSH
- **Admin account**: `orth`
### Repos on Gitea
| Repo | Branch | Push Mirror → GitHub |
| --------------------- | ------- | -------------------- |
| `layonara/nwn-module` | `ee` | Yes, sync on commit |
| `layonara/nwn-haks` | `64bit` | Yes, sync on commit |
### NOT on Gitea
- `plenarius/unified` (NWNX) stays on GitHub — read-only, no contributions through Forge
### Branch Protection
- `ee` on nwn-module: only `orth` can push directly
- `64bit` on nwn-haks: only `orth` can push directly
- Contributors must fork within the layonara org and PR
### Push Mirrors
Each Gitea repo has a push mirror configured to sync to the corresponding GitHub repo. This triggers existing GitHub CI/CD and Discord bot webhooks. Commit attribution is preserved (it's in the git objects).
### How the Forge Connects
- `GIT_PROVIDER_URL` env var (default: `https://gitea.layonara.com`)
- Backend uses `git-provider.service.ts` which calls Gitea API at `$GIT_PROVIDER_URL/api/v1`
- Clone URLs: `https://<token>@gitea.layonara.com/<user>/<repo>.git`
- The `unified` repo clones from GitHub directly (no token needed, public repo)
## Forked Dependencies
### layonara/neverwinter.nim (fork of niv/neverwinter.nim)
- **Purpose**: Adds `-n` (no entry point) and `-E` (all errors) flags to `nwn_script_comp`
- **Cherry-picked**: PRs #152 and #153 from cgtudor's branches
- **Release workflow**: `.github/workflows/release.yml` — builds on tag push, creates GitHub Release with pre-built linux tarball
- **Current release**: `v2.1.2-layonara`
- **Upstream status**: Waiting for niv to merge the PRs. When merged, this fork can be retired.
- **Remote setup**: `origin` = layonara fork, `upstream` = niv/neverwinter.nim (push disabled: `no_push`)
### layonara/nwscript-ee-language-server (fork of PhilippeChab/nwscript-ee-language-server)
- **Purpose**: Migrates diagnostics from `nwnsc` to `nwn_script_comp`
- **Merged**: PR #77 (cgtudor's tdn-hack branch) with conflict resolution
- **Status**: Waiting for upstream compiler PRs to merge, then PhilippeChab can merge PR #77, then this fork can be retired.
- **Included in Forge**: As a git submodule at `lsp/nwscript-language-server/`
- **Remote setup**: `origin` = layonara fork, `upstream` = PhilippeChab (push disabled: `no_push`)
### plenarius/layonara_nwn
- **Not a fork**: James's own repo
- **Release workflow added**: `.github/workflows/release.yml` — builds on tag push
- **Current release**: `v0.1.1`
## Known Issues & Incomplete Work
### TypeScript
- All backend TS errors are resolved (Express 5 `*path` returns `string[]`, simple-git uses named import)
- Frontend uses `"moduleResolution": "bundler"` and `"noImplicitAny": false` in tsconfig to avoid strict-mode issues from subagent-generated code
- Frontend build script is `"build": "vite build"` (skips `tsc -b` which fights with bundler resolution)
### Not Yet Tested End-to-End
- Full setup wizard flow against live Gitea (the UI renders but hasn't been walked through with real forking/cloning)
- Module build → server start → connect with NWN client
- Toolset temp0/ watcher with real GFF files
- Hot-reload pipeline with a real nasher build
- Push mirrors actually triggering GitHub CI
- LSP with real `.nss` files (the bridge architecture is built but URI mapping may need tuning)
### Remaining UI Work
- **GFF visual editors** (ItemEditor, CreatureEditor, AreaEditor, DialogEditor) still use Tailwind classes — may need inline style conversion
- **CommitDialog** component styling could be improved
- **FileExplorer** sidebar styling uses older patterns (Tailwind classes for layout)
- **EditorTabs** uses older patterns
- **SearchPanel** uses older patterns
- **ErrorBoundary** component not styled
- **Light mode** needs visual verification — all tokens have light variants but the overall look hasn't been tested
- The vendor bundle is still 598KB (Monaco dominates) — could be split further with `manualChunks`
### Database
- `db/schema.sql` contains the full schema from James's local dev DB (`nwn_dev`) + seed data for cnr_misc and pwdata
- DM row insertion happens during setup wizard (contributor provides CD key)
- Architecture is ready for richer seed data (production dump) but not implemented yet
### Infrastructure
- Gitea on xandrial needs monitoring/backup strategy (no backup service configured yet)
- The Gitea PostgreSQL database should be backed up regularly
## Key Conventions
- **NEVER close GitHub issues** without explicit permission — state changes fire Discord webhooks to the player community
- **NEVER push to `nwnxee/unified`** — James's fork is `plenarius/unified`
- **No pushing layonara-forge to any remote** until James says so — everything is local
- **Conventional commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
- **NWN resref limit**: 16 characters max for all filenames
- **Express 5**: Uses `*path` for catch-all/wildcard routes, NOT bare `*`
- **simple-git**: Use named import `import { simpleGit } from "simple-git"`, NOT default import
- **Inline styles for layout**: Do NOT use Tailwind classes for flex/grid layout — they don't reliably apply. Use inline `style={{}}` props.
- **CSS variables for all colors**: Never use hex values. Use `var(--forge-*)` tokens from `globals.css`.
- **lucide-react for icons**: Never use Unicode emoji for UI icons. Import from `lucide-react`.
## Environment Variables
### Forge Container
| Var | Default | Purpose |
| ------------------ | ---------------------------- | -------------------------------------------- |
| `WORKSPACE_PATH` | `/workspace` | Where repos, server data, config live |
| `NWN_HOME_PATH` | `/nwn-home` | NWN documents directory (for Toolset temp0/) |
| `GIT_PROVIDER_URL` | `https://gitea.layonara.com` | Gitea instance URL |
| `PORT` | `3000` | HTTP server port |
### Coolify
| Var | Location | Purpose |
| ------------------- | ---------------- | --------------------------------- |
| `COOLIFY_API_TOKEN` | `~/.env.coolify` | API access to Coolify on leanthar |
| `COOLIFY_URL` | `~/.env.coolify` | `https://leanthar.layonara.com` |
### Gitea
| Item | Value |
| ----------------------------- | ---------------------------------------------------------------------------- |
| API token | `eb79a92cea7dad657a0c81ddd2290a1be95057e2` (orth's token, name: forge-setup) |
| Gitea service UUID in Coolify | `xo2yy8rml79lkzmf92cgeory` |
## File Structure
```
layonara-forge/
├── Dockerfile # Forge image (multi-stage Node.js build)
├── docker-compose.yml # Forge container with socket + workspace mounts
├── .env.example # WORKSPACE_PATH, NWN_HOME_PATH, GIT_PROVIDER_URL
├── .impeccable.md # Design context (personality, palette, principles)
├── .agents/skills/ # Impeccable design skills (18 commands)
├── builder/
│ └── Dockerfile # Builder image (pre-built binaries, no Nim)
├── db/
│ └── schema.sql # MariaDB schema + seed data
├── lsp/
│ └── nwscript-language-server/ # Git submodule (forked LSP)
├── packages/
│ ├── backend/
│ │ └── src/
│ │ ├── index.ts # Express + WS + upgrade handlers
│ │ ├── config/ # repos.ts, env-template.ts
│ │ ├── gff/ # GFF schema definitions (6 types)
│ │ ├── nwscript/ # resref-index, tlk-index, twoda-index
│ │ ├── routes/ # All API routes
│ │ └── services/ # All backend services
│ └── frontend/
│ └── src/
│ ├── App.tsx # Router with SetupGuard + React.lazy routes
│ ├── components/ # editor/, gff/, terminal/, Toast, etc.
│ ├── hooks/ # useWebSocket, useEditorState, useTheme, useLspClient
│ ├── layouts/ # IDELayout (inline styles), SetupLayout
│ ├── lib/ # lspClient.ts (JSON-RPC bridge)
│ ├── pages/ # All page components (inline styles, lucide icons)
│ ├── services/ # api.ts
│ └── styles/ # globals.css (OKLCH tokens, font imports, global styles)
```
## Running Locally
### Native (dev mode, fastest iteration)
```bash
cd /home/jmg/dev/layonara/layonara-forge
WORKSPACE_PATH=/tmp/forge-test NWN_HOME_PATH=/home/jmg/dev/nwn/local-server/home GIT_PROVIDER_URL=https://gitea.layonara.com npm run dev
# Frontend: http://localhost:5173 (proxies to backend)
# Backend: http://localhost:3000
```
**Important**: If you run without `WORKSPACE_PATH`, it defaults to `/workspace` which doesn't exist natively. The File Explorer will show "Repository not cloned" and repos will show as uncloned.
### Docker (production mode)
```bash
cd /home/jmg/dev/layonara/layonara-forge
cp .env.example .env # edit paths
docker compose up -d
# Open http://localhost:3000
```
### Building Docker images
```bash
docker build -t layonara-builder builder/ # ~45 seconds
docker build -t layonara-forge . # ~2 minutes
```
## Current State (as of UI overhaul session end)
- All code is local — nothing has been pushed to any remote for the layonara-forge repo itself
- Frontend build passes clean (no TS errors, no lint issues)
- All 8 pages styled with consistent design system (cards, icons, tokens)
- Setup wizard has 4-phase indicator, path inputs, status dots
- Routes are code-split (10 chunks)
- 28 frontend files modified in the UI overhaul
- The `layonara/neverwinter.nim` and `layonara/nwscript-ee-language-server` forks are on GitHub with changes pushed
- `plenarius/layonara_nwn` has a release workflow added and v0.1.1 release published
## Priority for Next Session
1. **Integration test the setup wizard** against live Gitea (fork, clone, build cycle) — needs `git` installed or Docker environment
2. **Test module build → server start → NWN client connection**
3. **Test Toolset temp0/ sync** with real GFF files
4. **Polish remaining components** (GFF editors, CommitDialog, FileExplorer, EditorTabs, SearchPanel — still use Tailwind classes)
5. **Test light mode** visually
6. **Set up Gitea backup** on xandrial
+40
View File
@@ -743,6 +743,33 @@
"node": ">=18"
}
},
"node_modules/@fontsource-variable/alegreya": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/alegreya/-/alegreya-5.2.8.tgz",
"integrity": "sha512-gQcIA7j76KYTOcdkfo1Xee9xLBi5mya4qTkzlgeoHf9SjOL/gJj5GSSOg/7ba/ciUU18K92i7VGxXzFhDsowGg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
@@ -2922,6 +2949,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4896,9 +4932,13 @@
"name": "@layonara-forge/frontend",
"version": "0.0.1",
"dependencies": {
"@fontsource-variable/alegreya": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
-2
View File
@@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layonara Forge</title>
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+4
View File
@@ -9,9 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/alegreya": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+47 -32
View File
@@ -1,13 +1,14 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useState, useCallback, useEffect } from "react";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor";
import { Build } from "./pages/Build";
import { Server } from "./pages/Server";
import { Toolset } from "./pages/Toolset";
import { Repos } from "./pages/Repos";
import { Settings } from "./pages/Settings";
import { Setup } from "./pages/Setup";
import { useState, useCallback, useEffect, lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
const Settings = lazy(() => import("./pages/Settings").then(m => ({ default: m.Settings })));
const Setup = lazy(() => import("./pages/Setup").then(m => ({ default: m.Setup })));
import { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer";
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module";
function PageLoader() {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
<span className="text-sm">Loading</span>
</div>
);
}
function SetupGuard({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
.finally(() => setChecking(false));
}, []);
if (checking) return null;
if (checking) return (
<div className="flex h-screen items-center justify-center" style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
<span style={{ fontFamily: "var(--font-heading)" }}>Loading Forge</span>
</div>
);
if (needsSetup) return <Navigate to="/setup" replace />;
return <>{children}</>;
}
@@ -73,29 +86,31 @@ export function App() {
<ToastProvider>
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/setup" element={<SetupLayout />}>
<Route index element={<Setup />} />
</Route>
<Route
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/setup" element={<SetupLayout />}>
<Route index element={<Setup />} />
</Route>
<Route
path="/editor"
element={<Editor editorState={editorState} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/editor"
element={<Editor editorState={editorState} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</ErrorBoundary>
</ToastProvider>
@@ -104,13 +104,13 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "'JetBrains Mono', monospace" }}>
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "var(--font-mono)" }}>
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
</div>
{error && (
<div className="mb-3 rounded bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</div>
<div className="mb-3 rounded px-3 py-2 text-sm" style={{ backgroundColor: "var(--forge-danger-bg)", color: "var(--forge-danger)" }}>{error}</div>
)}
<div className="flex justify-end gap-2">
@@ -125,7 +125,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onClick={() => handleSubmit(false)}
disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
>
Commit
</button>
@@ -133,7 +133,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onClick={() => handleSubmit(true)}
disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
style={{ backgroundColor: "var(--forge-warning-bg)", borderColor: "var(--forge-warning-border)", color: "var(--forge-warning)" }}
>
Commit & Push
</button>
@@ -25,10 +25,10 @@ export function ErrorDisplay({
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid #7f1d1d",
border: "1px solid var(--forge-danger-border)",
}}
>
<h3 className="text-lg font-semibold" style={{ color: "#fca5a5" }}>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-danger)" }}>
{title}
</h3>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
@@ -50,7 +50,7 @@ export function ErrorDisplay({
style={{
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
}}
>
{fullLog}
@@ -64,7 +64,7 @@ export function ErrorDisplay({
<button
onClick={onRetry}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
>
Retry
</button>
+5 -5
View File
@@ -29,9 +29,9 @@ export function useToast() {
}
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
success: { bg: "#052e16", border: "#166534", text: "#4ade80" },
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" },
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" },
success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
};
const AUTO_DISMISS: Record<ToastType, number | null> = {
@@ -49,7 +49,7 @@ function ToastItem({
}) {
const { bg, border, text } = COLORS[toast.type];
const timeout = AUTO_DISMISS[toast.type];
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (timeout) {
@@ -92,7 +92,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
<div aria-live="polite" role="status" className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
@@ -56,16 +56,18 @@ export function EditorTabs({
/>
)}
</span>
<span
<button
type="button"
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onClose(tab.path);
}}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
style={{ color: "var(--forge-text-secondary)" }}
style={{ appearance: "none", border: "none", background: "transparent", cursor: "pointer", color: "var(--forge-text-secondary)" }}
>
×
</span>
</button>
</button>
);
})}
@@ -87,7 +87,7 @@ function FileTreeNode({
paddingLeft: `${depth * 16 + 8}px`,
backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "13px",
}}
>
@@ -186,8 +186,17 @@ export function FileExplorer({
)}
{error && (
<div className="px-3 py-4 text-sm text-red-400">
{error}
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{error.includes("ENOENT") ? (
<div>
<p style={{ margin: 0, fontWeight: 500, color: "var(--forge-text)" }}>Repository not cloned</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)" }}>
Clone repositories from the Repos page or run the setup wizard.
</p>
</div>
) : (
<p style={{ margin: 0, color: "var(--forge-danger)" }}>{error}</p>
)}
</div>
)}
@@ -167,13 +167,14 @@ function registerNWScript(monaco: Parameters<OnMount>[1]) {
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
const bg = style.getPropertyValue("--forge-bg").trim() || "#1f1a14";
const surface = style.getPropertyValue("--forge-surface").trim() || "#2a2419";
const accent = style.getPropertyValue("--forge-accent").trim() || "#b07a1a";
const text = style.getPropertyValue("--forge-text").trim() || "#ede8e0";
const textSecondary =
style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
style.getPropertyValue("--forge-text-secondary").trim() || "#9a9080";
const border = style.getPropertyValue("--forge-border").trim() || "#3d3528";
const accentSubtle = style.getPropertyValue("--forge-accent-subtle").trim() || "#2e2818";
monaco.editor.defineTheme("forge-dark", {
base: "vs-dark",
@@ -196,7 +197,7 @@ function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
"editor.lineHighlightBackground": surface,
"editorLineNumber.foreground": textSecondary,
"editorLineNumber.activeForeground": text,
"editor.selectionBackground": "#264f7840",
"editor.selectionBackground": accentSubtle,
"editorWidget.background": surface,
"editorWidget.border": border,
"editorSuggestWidget.background": surface,
@@ -82,9 +82,9 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
backgroundColor: active ? "var(--forge-accent)" : "transparent",
color: active ? "#121212" : "var(--forge-text-secondary)",
color: active ? "var(--forge-accent-text)" : "var(--forge-text-secondary)",
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "12px",
lineHeight: "1",
});
@@ -119,7 +119,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)",
border: "1px solid var(--forge-border)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "13px",
}}
/>
@@ -172,7 +172,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
style={{
backgroundColor: "var(--forge-accent)",
color: "#121212",
color: "var(--forge-accent-text)",
}}
>
{loading ? "Searching..." : "Search"}
@@ -181,7 +181,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<div className="flex-1 overflow-y-auto">
{error && (
<div className="px-3 py-2 text-sm text-red-400">{error}</div>
<div className="px-3 py-2 text-sm" style={{ color: "var(--forge-danger)" }}>{error}</div>
)}
{searched && !loading && !error && (
@@ -213,7 +213,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</span>
<span
className="flex-1 truncate font-medium"
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }}
style={{ fontFamily: "var(--font-mono)", fontSize: "12px" }}
>
{group.file}
</span>
@@ -240,7 +240,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
className="shrink-0 text-xs"
style={{
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "11px",
minWidth: "32px",
textAlign: "right",
@@ -251,7 +251,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<span
className="truncate text-xs"
style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "12px",
color: "var(--forge-text-secondary)",
}}
@@ -129,8 +129,8 @@ function DialogNodeItem({
<span
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
style={{
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20",
color: type === "entry" ? "#60a5fa" : "#4ade80",
backgroundColor: type === "entry" ? "var(--forge-info-bg)" : "var(--forge-success-bg)",
color: type === "entry" ? "var(--forge-info)" : "var(--forge-success)",
}}
>
{type === "entry" ? "E" : "R"}{index}
@@ -287,7 +287,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
onClick={handleSave}
disabled={!dirty || saving}
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#fff" }}
style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
>
{saving ? "Saving..." : "Save"}
</button>
@@ -317,7 +317,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<p className="mb-4 text-sm" style={{ color: "#ef4444" }}>{error}</p>
<p className="mb-4 text-sm" style={{ color: "var(--forge-danger)" }}>{error}</p>
)}
{activeTab === "tree" && (
@@ -215,13 +215,13 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: valid ? "var(--forge-border)" : "#ef4444",
borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)",
color: "var(--forge-text)",
}}
/>
<span
className="text-xs"
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }}
style={{ color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)" }}
>
{str.length}/16
</span>
@@ -421,7 +421,7 @@ export function GffEditor({
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p>
<p className="text-sm" style={{ color: "var(--forge-danger)" }}>{error}</p>
{onSwitchToRaw && (
<button
onClick={onSwitchToRaw}
@@ -461,7 +461,7 @@ export function GffEditor({
</span>
)}
{error && (
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span>
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>{error}</span>
)}
</div>
<div className="flex items-center gap-2">
@@ -480,7 +480,7 @@ export function GffEditor({
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{
backgroundColor: "var(--forge-accent)",
color: "#fff",
color: "var(--forge-accent-text)",
}}
>
{saving ? "Saving..." : "Save"}
@@ -141,7 +141,7 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
</span>
<button
className="text-xs"
style={{ color: "#ef4444" }}
style={{ color: "var(--forge-danger)" }}
>
Remove
</button>
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
useEffect(() => {
if (!containerRef.current) return;
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim();
const fg = style.getPropertyValue("--forge-text").trim();
const accent = style.getPropertyValue("--forge-accent").trim();
const secondary = style.getPropertyValue("--forge-text-secondary").trim();
const accentHover = style.getPropertyValue("--forge-accent-hover").trim();
const term = new XTerm({
theme: {
background: "#121212",
foreground: "#f2f2f2",
cursor: "#946200",
selectionBackground: "#946200",
selectionForeground: "#f2f2f2",
black: "#121212",
brightBlack: "#666666",
white: "#f2f2f2",
brightWhite: "#ffffff",
yellow: "#946200",
brightYellow: "#c48800",
background: bg,
foreground: fg,
cursor: accent,
selectionBackground: accent,
selectionForeground: fg,
black: bg,
brightBlack: secondary,
white: fg,
brightWhite: fg,
yellow: accent,
brightYellow: accentHover,
},
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: 13,
cursorBlink: true,
});
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: "#121212" }}
style={{ backgroundColor: "var(--forge-bg)" }}
/>
);
}
+102 -47
View File
@@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { Terminal } from "../components/terminal/Terminal";
import { useWebSocket } from "../hooks/useWebSocket";
import { useTheme } from "../hooks/useTheme";
import {
Code2,
Wrench,
Hammer,
Play,
GitBranch,
Settings,
Sun,
Moon,
Terminal as TerminalIcon,
ChevronDown,
ChevronUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
const NAV_ITEMS = [
{ path: "/editor", label: "Editor", icon: "\u270E" },
{ path: "/toolset", label: "Toolset", icon: "\u2699" },
{ path: "/build", label: "Build", icon: "\u2692" },
{ path: "/server", label: "Server", icon: "\u25B6" },
{ path: "/repos", label: "Repos", icon: "\u2387" },
{ path: "/settings", label: "Settings", icon: "\u2318" },
const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
{ path: "/editor", label: "Editor", Icon: Code2 },
{ path: "/toolset", label: "Toolset", Icon: Wrench },
{ path: "/build", label: "Build", Icon: Hammer },
{ path: "/server", label: "Server", Icon: Play },
{ path: "/repos", label: "Repos", Icon: GitBranch },
{ path: "/settings", label: "Settings", Icon: Settings },
];
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
const navigate = useNavigate();
const { subscribe } = useWebSocket();
const { theme, toggleTheme } = useTheme();
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
useEffect(() => {
return subscribe("git:upstream-update", (event) => {
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
};
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
<div style={{ display: "flex", height: "100vh", overflow: "hidden", backgroundColor: "var(--forge-bg)" }}>
{/* Left sidebar nav */}
<nav
className="flex shrink-0 flex-col"
aria-label="Main navigation"
style={{
display: "flex",
flexDirection: "column",
width: "56px",
flexShrink: 0,
borderRight: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
}}
>
<Link
to="/"
className="flex items-center justify-center py-3"
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
title="Dashboard"
>
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} />
<img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
</Link>
<div className="mt-2 flex flex-1 flex-col">
<div style={{ marginTop: "0.25rem", display: "flex", flexDirection: "column", flex: 1 }}>
{NAV_ITEMS.map((item) => {
const isActive =
item.path === "/"
@@ -115,20 +133,26 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<Link
key={item.path}
to={item.path}
className="relative flex flex-col items-center justify-center py-2.5 text-center transition-colors hover:bg-white/5"
style={{
borderLeft: isActive
? "3px solid var(--forge-accent)"
: "3px solid transparent",
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0.625rem 0",
position: "relative",
textDecoration: "none",
transition: "background-color 150ms, color 150ms",
backgroundColor: isActive ? "var(--forge-accent-subtle)" : undefined,
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? "var(--forge-accent-subtle)" : ""; }}
title={item.label}
>
<span className="text-base">{item.icon}</span>
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span>
<item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
{badge > 0 && (
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[8px] font-bold text-black">
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-0.5 text-[8px] font-bold" style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}>
{badge}
</span>
)}
@@ -139,69 +163,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<button
onClick={toggleTheme}
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0.625rem 0",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
width: "100%",
transition: "background-color 150ms, color 150ms",
}}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{theme === "dark" ? "Light" : "Dark"}</span>
</button>
</nav>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
<header
className="flex shrink-0 items-center gap-4 px-4 py-1.5"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "0.375rem 1rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<div className="flex items-center gap-2">
<span
className="text-lg font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</span>
</div>
<div className="flex-1" />
<span
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-lg)",
fontWeight: 700,
color: "var(--forge-accent)",
}}
>
Layonara Forge
</span>
</header>
<div className="flex flex-1 overflow-hidden">
{sidebar && (
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{sidebar && showSidebar && (
<aside
className="shrink-0 overflow-hidden"
style={{
width: "250px",
flexShrink: 0,
overflow: "hidden",
borderRight: "1px solid var(--forge-border)",
}}
>
{sidebar}
</aside>
)}
<main className="flex-1 overflow-hidden">
<main style={{ flex: 1, overflow: "hidden" }}>
<Outlet />
</main>
</div>
<button
onClick={() => setTerminalOpen((v) => !v)}
className="flex shrink-0 items-center gap-1 px-3 py-0.5 text-xs transition-colors hover:bg-white/5"
style={{
borderTop: "1px solid var(--forge-border)",
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.75rem",
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
background: "none",
border: "none",
borderTop: "1px solid var(--forge-border)",
width: "100%",
cursor: "pointer",
transition: "background-color 150ms",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
<span>{terminalOpen ? "\u25BC" : "\u25B2"}</span>
<TerminalIcon size={12} />
<span>Terminal</span>
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
{terminalOpen && (
<div
className="shrink-0 overflow-hidden"
style={{
height: "300px",
flexShrink: 0,
overflow: "hidden",
borderTop: "1px solid var(--forge-border)",
}}
>
+25 -12
View File
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
export function SetupLayout() {
return (
<div
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
style={{
minHeight: "100vh",
backgroundColor: "var(--forge-bg)",
backgroundImage: "linear-gradient(rgba(0,0,0,0.75), rgba(0,0,0,0.85)), url('/page-bg.jpg')",
backgroundImage: "linear-gradient(oklch(15% 0.015 65 / 0.85), oklch(12% 0.01 65 / 0.92)), url('/page-bg.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
padding: "2rem",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<div className="w-full max-w-2xl">
<h1
className="mb-8 text-center text-3xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</h1>
<div style={{ width: "100%", maxWidth: "52rem", marginTop: "4vh" }}>
<div style={{ marginBottom: "2rem" }}>
<h1
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-2xl)",
fontWeight: 700,
color: "var(--forge-accent)",
margin: 0,
}}
>
Layonara Forge
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Development environment setup
</p>
</div>
<Outlet />
</div>
</div>
+214 -70
View File
@@ -1,6 +1,17 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Hammer,
Package,
Cpu,
Play,
Archive,
Upload,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
type BuildStatus = "idle" | "building" | "success" | "failed";
@@ -11,15 +22,38 @@ interface BuildSectionState {
}
function StatusBadge({ status }: { status: BuildStatus }) {
const colors: Record<BuildStatus, string> = {
idle: "bg-gray-500/20 text-gray-400",
building: "bg-yellow-500/20 text-yellow-400",
success: "bg-green-500/20 text-green-400",
failed: "bg-red-500/20 text-red-400",
const styles: Record<BuildStatus, React.CSSProperties> = {
idle: {
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
},
building: {
backgroundColor: "var(--forge-warning-bg)",
color: "var(--forge-warning)",
},
success: {
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
},
failed: {
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
},
};
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
<span
style={{
...styles[status],
borderRadius: "9999px",
padding: "0.125rem 0.625rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
textTransform: "uppercase" as const,
letterSpacing: "0.03em",
}}
>
{status}
</span>
);
@@ -43,31 +77,47 @@ function BuildOutput({
}, [lines, collapsed]);
return (
<div className="mt-2">
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={onToggle}
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
cursor: "pointer",
padding: "0.25rem 0",
fontFamily: "var(--font-sans)",
}}
>
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span>Output ({lines.length} lines)</span>
</button>
{!collapsed && (
<div
ref={scrollRef}
className="mt-1 max-h-64 overflow-auto rounded p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
marginTop: "0.5rem",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.5rem",
padding: "0.875rem 1rem",
backgroundColor: "var(--forge-log-bg)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
lineHeight: "1.6",
}}
>
{lines.length === 0 ? (
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
<span style={{ color: "var(--forge-text-secondary)", fontStyle: "italic" }}>
No output yet
</span>
) : (
lines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
@@ -83,27 +133,29 @@ function ActionButton({
onClick,
disabled,
variant = "default",
icon,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "warning";
icon?: React.ReactNode;
}) {
const styles = {
const variantStyles: Record<string, React.CSSProperties> = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
border: "none",
color: "var(--forge-accent-text)",
},
warning: {
backgroundColor: "#854d0e",
borderColor: "#a16207",
color: "#fef08a",
backgroundColor: "var(--forge-warning-bg)",
border: "1px solid var(--forge-warning-border)",
color: "var(--forge-warning)",
},
};
@@ -111,9 +163,22 @@ function ActionButton({
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
style={{
...variantStyles[variant],
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
transition: "opacity 0.15s ease",
}}
>
{icon}
{label}
</button>
);
@@ -191,41 +256,103 @@ export function Build() {
[],
);
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
const isBuilding =
module.status === "building" || haks.status === "building" || nwnx.status === "building";
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1rem",
};
const sectionHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
};
const sectionTitleStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
textTransform: "uppercase",
fontSize: "var(--text-xs)",
fontWeight: 600,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-heading)",
};
const buttonRowStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "center",
};
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Build Pipeline
</h2>
<div
style={{
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Build Pipeline
</h2>
<p
style={{
fontFamily: "var(--font-sans)",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.375rem 0 0 0",
}}
>
Compile, pack, and deploy module resources
</p>
</div>
{/* Module Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Module</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Hammer size={14} />
<span>Module</span>
</div>
<StatusBadge status={module.status} />
</div>
<div className="flex flex-wrap gap-2">
<div style={buttonRowStyle}>
<ActionButton
label="Compile"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")}
/>
<ActionButton
label="Pack Module"
icon={<Archive size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")}
/>
<ActionButton
label="Deploy to Server"
variant="warning"
icon={<Upload size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.deploy(), "module")}
/>
@@ -238,21 +365,19 @@ export function Build() {
</section>
{/* Haks Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Haks</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Package size={14} />
<span>Haks</span>
</div>
<StatusBadge status={haks.status} />
</div>
<div className="flex gap-2">
<div style={buttonRowStyle}>
<ActionButton
label="Build Haks"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
/>
@@ -265,38 +390,49 @@ export function Build() {
</section>
{/* NWNX Section */}
<section
className="rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Cpu size={14} />
<span>NWNX</span>
<span
style={{
fontWeight: 400,
textTransform: "none",
opacity: 0.6,
letterSpacing: "normal",
}}
>
(Advanced)
</span>
</div>
<StatusBadge status={nwnx.status} />
</div>
<div className="mb-3 flex flex-wrap gap-2">
<div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
<ActionButton
label="Build All"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/>
</div>
<div className="flex items-center gap-2">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
type="text"
value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)"
className="rounded border px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
outline: "none",
flex: "0 1 16rem",
}}
/>
<ActionButton
@@ -308,10 +444,18 @@ export function Build() {
/>
</div>
<p
className="mt-2 text-xs"
style={{ color: "#f59e0b" }}
style={{
marginTop: "0.75rem",
marginBottom: 0,
fontSize: "var(--text-xs)",
color: "var(--forge-warning)",
display: "flex",
alignItems: "center",
gap: "0.375rem",
}}
>
Requires server restart to pick up changes
<AlertTriangle size={12} />
Requires server restart to pick up changes
</p>
<BuildOutput
lines={nwnx.output}
+119 -103
View File
@@ -1,21 +1,68 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { Server, GitBranch, Hammer, Code2, Terminal, Database, ArrowRight } from "lucide-react";
const card: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
};
const cardTitle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
margin: 0,
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const statusDot = (color: string): React.CSSProperties => ({
width: "0.5rem",
height: "0.5rem",
borderRadius: "50%",
backgroundColor: color,
flexShrink: 0,
});
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
width: "100%",
transition: "background-color 150ms",
};
function StatusBadge({ status }: { status: string }) {
const color =
status === "running"
? "#4ade80"
: status === "stopped"
? "#f87171"
: "#fbbf24";
? "var(--forge-success)"
: status === "stopped" || status === "exited" || status === "not created"
? "var(--forge-danger)"
: "var(--forge-warning)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold"
style={{ backgroundColor: `${color}20`, color }}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
fontWeight: 500,
color,
}}
>
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span style={statusDot(color)} />
{status}
</span>
);
@@ -57,49 +104,36 @@ function ServerCard() {
}
};
const isRunning = status.nwserver === "running";
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Server Status
</h3>
<div className="mt-4 flex items-center gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
NWServer
</span>
<StatusBadge status={status.nwserver} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
MariaDB
</span>
<StatusBadge status={status.mariadb} />
</div>
<div style={card}>
<h3 style={cardTitle}><Server size={14} /> Server</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
<StatusBadge status={status.nwserver} />
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
<StatusBadge status={status.mariadb} />
</div>
</div>
<div className="mt-4">
<div style={{ marginTop: "1rem" }}>
<button
onClick={toggle}
disabled={loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{
backgroundColor:
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)",
color: status.nwserver === "running" ? "#fca5a5" : "#000",
...primaryBtn,
backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
color: isRunning ? "var(--forge-danger)" : "var(--forge-accent-text)",
border: isRunning ? "1px solid var(--forge-danger-border)" : "none",
opacity: loading ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = loading ? "0.5" : "1"; }}
>
{loading
? "..."
: status.nwserver === "running"
? "Stop Server"
: "Start Server"}
{loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
</button>
</div>
</div>
@@ -120,39 +154,32 @@ function ReposSummary() {
}, []);
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Repositories
</h3>
<div className="mt-4 space-y-2">
{repos.map((repo) => {
<div style={card}>
<h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
{repos.map((repo, i) => {
const s = repoStatus[repo];
const branch = (s?.branch as string) || "\u2014";
const clean = s?.clean !== false;
return (
<div
key={repo}
className="flex items-center justify-between rounded p-3"
style={{ backgroundColor: "var(--forge-bg)" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0",
borderTop: i > 0 ? "1px solid var(--forge-border)" : undefined,
}}
>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{repo}
</span>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{branch}
</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
title={clean ? "Clean" : "Uncommitted changes"}
/>
<span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
</div>
</div>
);
@@ -166,41 +193,38 @@ function QuickActions() {
const navigate = useNavigate();
const actions = [
{ label: "Build Module", onClick: () => navigate("/build") },
{ label: "Build Haks", onClick: () => navigate("/build") },
{ label: "Open Editor", onClick: () => navigate("/editor") },
{
label: "Open Terminal",
onClick: () => {
/* terminal is toggled from IDELayout via Ctrl+` */
navigate("/editor");
},
},
{ label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
{ label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
{ label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
];
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Quick Actions
</h3>
<div className="mt-4 grid grid-cols-2 gap-2">
<div style={card}>
<h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{actions.map((a) => (
<button
key={a.label}
onClick={a.onClick}
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
style={{
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
display: "flex",
alignItems: "center",
gap: "0.625rem",
padding: "0.5rem 0.75rem",
borderRadius: "0.375rem",
border: "1px solid var(--forge-border)",
background: "none",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
textAlign: "left" as const,
transition: "background-color 150ms, border-color 150ms",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; e.currentTarget.style.borderColor = "var(--forge-text-secondary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; e.currentTarget.style.borderColor = "var(--forge-border)"; }}
>
<a.Icon size={15} style={{ color: "var(--forge-text-secondary)" }} />
{a.label}
</button>
))}
@@ -211,24 +235,16 @@ function QuickActions() {
export function Dashboard() {
return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-8 text-center">
<h1
className="text-3xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "56rem", margin: "0 auto" }}>
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
Dashboard
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
NWN Development Environment
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Server, repositories, and quick actions
</p>
</div>
<div className="mx-auto max-w-3xl space-y-4">
<ServerCard />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
<ServerCard />
<ReposSummary />
<QuickActions />
</div>
+57 -11
View File
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
import { CreatureEditor } from "../components/gff/CreatureEditor";
import { AreaEditor } from "../components/gff/AreaEditor";
import { DialogEditor } from "../components/gff/DialogEditor";
import { FileCode, Code2, Eye } from "lucide-react";
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
@@ -50,7 +51,6 @@ export function Editor({ editorState }: EditorProps) {
markClean,
} = editorState;
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
const tabs = useMemo(
@@ -105,8 +105,28 @@ export function Editor({ editorState }: EditorProps) {
const renderEditor = () => {
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center">
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: 12,
}}
>
<FileCode
size={48}
style={{ color: "var(--forge-text-secondary)", opacity: 0.4 }}
/>
<p
style={{
color: "var(--forge-text-secondary)",
fontSize: "var(--text-lg)",
fontFamily: "var(--font-heading)",
margin: 0,
}}
>
Open a file from the File Explorer to start editing
</p>
</div>
@@ -139,22 +159,41 @@ export function Editor({ editorState }: EditorProps) {
}
return (
<div className="flex h-full flex-col">
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{isActiveGff && activeMode === "raw" && (
<div
className="flex shrink-0 items-center justify-end border-b px-4 py-1"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
flexShrink: 0,
padding: "4px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
<button
onClick={handleSwitchToVisual}
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 12px",
borderRadius: 4,
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-mono)",
cursor: "pointer",
}}
>
<Eye size={13} />
Switch to Visual Editor
</button>
</div>
)}
<div className="flex-1 overflow-hidden">
<div style={{ flex: 1, overflow: "hidden" }}>
<MonacoEditor
key={activeTab}
filePath={activeFilePath}
@@ -167,14 +206,21 @@ export function Editor({ editorState }: EditorProps) {
};
return (
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "var(--forge-bg)",
}}
>
<EditorTabs
tabs={tabs}
activeTab={activeTab}
onSelect={selectTab}
onClose={closeFile}
/>
<div className="flex-1 overflow-hidden">
<div style={{ flex: 1, overflow: "hidden" }}>
{renderEditor()}
</div>
</div>
+473 -89
View File
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "../services/api";
import { CommitDialog } from "../components/CommitDialog";
import { useWebSocket } from "../hooks/useWebSocket";
import {
GitBranch,
GitCommit,
GitPullRequest,
Download,
Upload,
Copy,
FileCode,
AlertCircle,
CheckCircle,
X,
} from "lucide-react";
interface RepoStatus {
modified: string[];
@@ -26,6 +38,54 @@ interface PrForm {
body: string;
}
const badge = (
bg: string,
fg: string,
extra?: React.CSSProperties,
): React.CSSProperties => ({
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
padding: "0.15rem 0.55rem",
borderRadius: "9999px",
fontSize: "var(--text-xs)",
fontWeight: 600,
lineHeight: 1.4,
backgroundColor: bg,
color: fg,
whiteSpace: "nowrap",
...extra,
});
const btnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.4rem 0.85rem",
borderRadius: "0.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
border: "1px solid",
cursor: "pointer",
transition: "opacity 0.15s",
lineHeight: 1.4,
};
const outlineBtn: React.CSSProperties = {
...btnBase,
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
};
const accentBtn: React.CSSProperties = {
...btnBase,
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
};
export function Repos() {
const [repos, setRepos] = useState<RepoInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -133,106 +193,247 @@ export function Repos() {
const isDirty = (status?: RepoStatus) =>
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
const disabledStyle = (disabled: boolean | undefined): React.CSSProperties =>
disabled ? { opacity: 0.45, pointerEvents: "none" } : {};
if (loading) {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
Loading repositories...
<div style={{
display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-sans)",
fontSize: "var(--text-base)",
}}>
Loading repositories
</div>
);
}
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Repositories
</h2>
<div style={{
height: "100%",
overflowY: "auto",
padding: "1.75rem 2rem",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
}}>
{/* Page heading */}
<div style={{ marginBottom: "1.75rem" }}>
<h2 style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}>
Repositories
</h2>
<p style={{
margin: "0.3rem 0 0",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
}}>
Clone, sync, and manage your Layonara repos
</p>
</div>
{/* Error banner */}
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
padding: "0.65rem 1rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-danger-border)",
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
fontSize: "var(--text-sm)",
}}>
<AlertCircle size={16} />
<span style={{ flex: 1 }}>{error}</span>
<button
onClick={() => setError("")}
style={{
background: "none",
border: "none",
color: "var(--forge-danger)",
cursor: "pointer",
padding: "0.2rem",
display: "flex",
}}
>
<X size={14} />
</button>
</div>
)}
{/* PR success banner */}
{prResult && (
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400">
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a>
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
padding: "0.65rem 1rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
fontSize: "var(--text-sm)",
}}>
<CheckCircle size={16} />
<span style={{ flex: 1 }}>
PR created:{" "}
<a
href={prResult.url}
target="_blank"
rel="noreferrer"
style={{ color: "inherit", textDecoration: "underline" }}
>
{prResult.url}
</a>
</span>
<button
onClick={() => setPrResult(null)}
style={{
background: "none",
border: "none",
color: "var(--forge-success)",
cursor: "pointer",
padding: "0.2rem",
display: "flex",
}}
>
<X size={14} />
</button>
</div>
)}
<div className="space-y-4">
{/* Repo cards */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{repos.map((repo) => (
<section
key={repo.name}
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold">{repo.name}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{/* Card header */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "0.5rem",
marginBottom: "0.85rem",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
}}>
{repo.name}
</h3>
<span style={badge("var(--forge-accent-subtle)", "var(--forge-accent)")}>
<GitBranch size={12} />
{repo.branch}
</span>
{repo.cloned && repo.status && (
<>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${isDirty(repo.status) ? "bg-yellow-500/20 text-yellow-400" : "bg-green-500/20 text-green-400"}`}>
{isDirty(repo.status) ? "dirty" : "clean"}
</span>
{repo.status.behind > 0 && (
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
{repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream
{isDirty(repo.status) ? (
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
dirty
</span>
) : (
<span style={badge("var(--forge-success-bg)", "var(--forge-success)")}>
<CheckCircle size={11} />
clean
</span>
)}
{repo.status.behind > 0 && (
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
<Download size={11} />
{repo.status.behind} behind
</span>
)}
{repo.status.ahead > 0 && (
<span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-xs font-medium text-blue-400">
<span style={badge("var(--forge-info-bg)", "var(--forge-info)")}>
<Upload size={11} />
{repo.status.ahead} ahead
</span>
)}
</>
)}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-mono)",
}}>
{repo.upstream}
</span>
</div>
{/* Actions */}
{!repo.cloned ? (
<button
onClick={() => handleClone(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
>
{actionLoading[repo.name] ? "Cloning..." : "Clone"}
<Copy size={14} />
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
</button>
) : (
<>
<div className="mb-3 flex flex-wrap gap-2">
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
<button
onClick={() => handlePull(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
>
<Download size={14} />
Pull
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Push
</button>
<button
onClick={() => setCommitRepo(repo.name)}
disabled={!isDirty(repo.status)}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{
...accentBtn,
...disabledStyle(!isDirty(repo.status)),
}}
>
<GitCommit size={14} />
Commit
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
style={{
...outlineBtn,
...disabledStyle(actionLoading[repo.name] || !repo.status?.ahead),
}}
>
<Upload size={14} />
Push
</button>
<button
onClick={() =>
setPrForm({
@@ -241,39 +442,104 @@ export function Repos() {
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
})
}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
style={outlineBtn}
>
<GitPullRequest size={14} />
Create PR
</button>
</div>
{/* Changed files list */}
{repo.status && isDirty(repo.status) && (
<div className="mt-2">
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
Changes
<div style={{
border: "1px solid var(--forge-border)",
borderRadius: "0.5rem",
overflow: "hidden",
}}>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}>
<FileCode size={13} style={{ color: "var(--forge-text-secondary)" }} />
<span style={{
fontSize: "var(--text-xs)",
fontWeight: 600,
color: "var(--forge-text-secondary)",
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
}}>
Changes
</span>
</div>
<div className="space-y-0.5">
<div>
{repo.status.modified.map((f) => (
<div
key={f}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 text-xs transition-colors hover:bg-white/5"
onClick={() => handleShowDiff(repo.name, f)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
cursor: "pointer",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="font-medium text-yellow-400">M</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<span style={{
fontWeight: 700,
color: "var(--forge-warning)",
width: "1rem",
textAlign: "center",
}}>M</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
{repo.status.staged.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-green-400">S</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<div
key={f}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span style={{
fontWeight: 700,
color: "var(--forge-success)",
width: "1rem",
textAlign: "center",
}}>S</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
{repo.status.untracked.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-gray-400">?</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<div
key={f}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span style={{
fontWeight: 700,
color: "var(--forge-text-secondary)",
width: "1rem",
textAlign: "center",
}}>?</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
</div>
@@ -285,6 +551,7 @@ export function Repos() {
))}
</div>
{/* Commit dialog */}
{commitRepo && (
<CommitDialog
repo={commitRepo}
@@ -296,71 +563,188 @@ export function Repos() {
/>
)}
{/* PR form modal */}
{prForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}>
<div
onClick={() => setPrForm(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div
className="w-full max-w-lg rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "32rem",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
}}
>
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Create Pull Request {prForm.repo}
</h3>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1.25rem",
}}>
<GitPullRequest size={18} style={{ color: "var(--forge-accent)" }} />
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
color: "var(--forge-accent)",
}}>
Create Pull Request
</h3>
<span style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
marginLeft: "0.25rem",
}}>
{prForm.repo}
</span>
</div>
<input
type="text"
value={prForm.title}
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
placeholder="PR Title"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
marginBottom: "0.75rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-sans)",
boxSizing: "border-box",
outline: "none",
}}
/>
<textarea
value={prForm.body}
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
rows={8}
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
marginBottom: "1rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-mono)",
boxSizing: "border-box",
resize: "vertical",
outline: "none",
}}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setPrForm(null)}
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
<button onClick={() => setPrForm(null)} style={outlineBtn}>
Cancel
</button>
<button
onClick={handleCreatePr}
disabled={!prForm.title.trim() || actionLoading.pr}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{
...accentBtn,
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
}}
>
{actionLoading.pr ? "Creating..." : "Submit PR"}
<GitPullRequest size={14} />
{actionLoading.pr ? "Creating…" : "Submit PR"}
</button>
</div>
</div>
</div>
)}
{/* Diff modal */}
{diffView && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}>
<div
onClick={() => setDiffView(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div
className="h-3/4 w-3/4 overflow-auto rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
style={{
width: "75%",
height: "75%",
overflowY: "auto",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
display: "flex",
flexDirection: "column",
}}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Diff {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
</h3>
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<FileCode size={18} style={{ color: "var(--forge-accent)" }} />
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
color: "var(--forge-accent)",
}}>
{diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
</h3>
</div>
<button
onClick={() => setDiffView(null)}
style={{
...outlineBtn,
padding: "0.3rem 0.6rem",
}}
>
<X size={14} />
Close
</button>
</div>
<pre
className="whitespace-pre-wrap text-xs"
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }}
>
<pre style={{
flex: 1,
overflowY: "auto",
margin: 0,
padding: "1rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-log-bg)",
color: "var(--forge-log-text)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
whiteSpace: "pre-wrap",
lineHeight: 1.6,
}}>
{diffView.diff || "No changes"}
</pre>
</div>
+446 -160
View File
@@ -2,29 +2,151 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Server as ServerIcon,
Play,
Square,
RotateCcw,
FileCode,
ScrollText,
Database,
Search,
Trash2,
} from "lucide-react";
type ServerState = "running" | "exited" | "not created" | string;
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
const color =
const dotColor =
state === "running"
? "bg-green-500/20 text-green-400"
? "var(--forge-success)"
: state === "exited"
? "bg-red-500/20 text-red-400"
: "bg-gray-500/20 text-gray-400";
? "var(--forge-danger)"
: "var(--forge-warning)";
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
{label}:
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: dotColor,
flexShrink: 0,
}}
/>
<span
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
}}
>
{label}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-mono)",
}}
>
{state}
</span>
</div>
);
}
function HoverButton({
children,
onClick,
disabled,
bg,
bgHover,
border,
color,
style,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
bg: string;
bgHover: string;
border: string;
color: string;
style?: React.CSSProperties;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.4rem 0.85rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
borderRadius: "0.5rem",
border: `1px solid ${border}`,
backgroundColor: hovered && !disabled ? bgHover : bg,
color,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "background-color 0.15s, opacity 0.15s",
...style,
}}
>
{children}
</button>
);
}
function SectionHeader({
icon,
label,
}: {
icon: React.ReactNode;
label: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
}}
>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
{icon}
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
{label}
</span>
</div>
);
}
function ControlsPanel() {
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
nwserver: "unknown",
@@ -63,49 +185,75 @@ function ControlsPanel() {
return (
<section
className="rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}}
>
<h3 className="mb-3 text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
Server Controls
</h3>
<div className="mb-4 flex gap-4">
<SectionHeader icon={<ServerIcon size={16} />} label="Server Controls" />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "1.25rem",
marginBottom: "1.25rem",
}}
>
<StatusBadge label="NWN Server" state={status.nwserver} />
<StatusBadge label="MariaDB" state={status.mariadb} />
</div>
<div className="flex flex-wrap gap-2">
{(["start", "stop", "restart", "config"] as const).map((action) => (
<button
key={action}
onClick={() => handleAction(action)}
disabled={loading !== null}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor:
action === "start"
? "var(--forge-accent)"
: action === "stop"
? "#991b1b"
: "var(--forge-surface)",
borderColor:
action === "start"
? "var(--forge-accent)"
: action === "stop"
? "#dc2626"
: "var(--forge-border)",
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)",
}}
>
{loading === action
? "..."
: action === "config"
? "Generate Config"
: action.charAt(0).toUpperCase() + action.slice(1)}
</button>
))}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<HoverButton
onClick={() => handleAction("start")}
disabled={loading !== null}
bg="var(--forge-accent)"
bgHover="var(--forge-accent-hover)"
border="var(--forge-accent)"
color="var(--forge-accent-text)"
>
<Play size={14} />
{loading === "start" ? "Starting..." : "Start"}
</HoverButton>
<HoverButton
onClick={() => handleAction("stop")}
disabled={loading !== null}
bg="var(--forge-danger-bg)"
bgHover="var(--forge-danger-border)"
border="var(--forge-danger-border)"
color="var(--forge-danger)"
>
<Square size={14} />
{loading === "stop" ? "Stopping..." : "Stop"}
</HoverButton>
<HoverButton
onClick={() => handleAction("restart")}
disabled={loading !== null}
bg="transparent"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text)"
>
<RotateCcw size={14} />
{loading === "restart" ? "Restarting..." : "Restart"}
</HoverButton>
<HoverButton
onClick={() => handleAction("config")}
disabled={loading !== null}
bg="transparent"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text)"
>
<FileCode size={14} />
{loading === "config" ? "Generating..." : "Generate Config"}
</HoverButton>
</div>
</section>
);
@@ -141,65 +289,119 @@ function LogViewer() {
return (
<section
className="flex flex-col rounded-lg border"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
height: "350px",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
Server Logs
</h3>
<div className="flex-1" />
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
className="rounded border px-2 py-1 text-xs"
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
<ScrollText size={16} />
</span>
<span
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
width: "200px",
}}
/>
<button
onClick={() => setAutoScroll((v) => !v)}
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: autoScroll ? "var(--forge-accent)" : "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: autoScroll ? "#fff" : "var(--forge-text-secondary)",
}}
>
Auto-scroll
</button>
<button
onClick={() => setLines([])}
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
Server Logs
</span>
<div style={{ flex: 1 }} />
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
}}
>
<Search
size={13}
style={{
position: "absolute",
left: 8,
color: "var(--forge-text-secondary)",
pointerEvents: "none",
}}
/>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.3rem 0.5rem 0.3rem 1.75rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-sans)",
color: "var(--forge-text)",
width: 180,
outline: "none",
}}
/>
</div>
<HoverButton
onClick={() => setAutoScroll((v) => !v)}
bg={autoScroll ? "var(--forge-accent)" : "var(--forge-bg)"}
bgHover={
autoScroll ? "var(--forge-accent-hover)" : "var(--forge-surface-raised)"
}
border={autoScroll ? "var(--forge-accent)" : "var(--forge-border)"}
color={
autoScroll
? "var(--forge-accent-text)"
: "var(--forge-text-secondary)"
}
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.6rem" }}
>
Auto-scroll
</HoverButton>
<HoverButton
onClick={() => setLines([])}
bg="var(--forge-bg)"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text-secondary)"
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.5rem" }}
>
<Trash2 size={12} />
Clear
</button>
</HoverButton>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-auto p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
backgroundColor: "var(--forge-log-bg)",
color: "var(--forge-log-text)",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.6,
padding: "0.75rem 1rem",
overflowY: "auto",
height: 350,
borderRadius: "0 0 0.75rem 0.75rem",
}}
>
{filteredLines.length === 0 ? (
@@ -208,7 +410,7 @@ function LogViewer() {
</span>
) : (
filteredLines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
@@ -245,31 +447,57 @@ function SQLConsole() {
return (
<section
className="flex flex-col rounded-lg border"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
<Database size={16} />
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
SQL Console
</h3>
<div className="flex-1" />
</span>
<div style={{ flex: 1 }} />
{history.length > 0 && (
<select
onChange={(e) => setQuery(e.target.value)}
className="rounded border px-2 py-1 text-xs"
value=""
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.3rem 0.5rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-sans)",
color: "var(--forge-text-secondary)",
maxWidth: "200px",
maxWidth: 200,
outline: "none",
}}
value=""
>
<option value="" disabled>
History ({history.length})
@@ -281,21 +509,21 @@ function SQLConsole() {
))}
</select>
)}
<button
<HoverButton
onClick={execute}
disabled={loading || !query.trim()}
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
}}
bg="var(--forge-accent)"
bgHover="var(--forge-accent-hover)"
border="var(--forge-accent)"
color="var(--forge-accent-text)"
>
<Play size={14} />
{loading ? "Running..." : "Execute"}
</button>
</HoverButton>
</div>
<div style={{ height: "100px" }}>
<div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
<ReactMonacoEditor
value={query}
language="sql"
@@ -319,71 +547,95 @@ function SQLConsole() {
{error && (
<div
className="px-4 py-2 text-sm"
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }}
style={{
padding: "0.75rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--forge-danger)",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-danger-bg)",
}}
>
{error}
</div>
)}
{result && (
<div
className="max-h-64 overflow-auto"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
<div style={{ overflowX: "auto" }}>
{result.columns.length === 0 ? (
<div className="px-4 py-3 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<div
style={{
padding: "1rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
}}
>
Query executed successfully (no results)
</div>
) : (
<table className="w-full text-left text-xs">
<thead>
<tr>
{result.columns.map((col) => (
<th
key={col}
className="sticky top-0 px-3 py-2 font-medium"
style={{
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
color: "var(--forge-accent)",
fontFamily: "'JetBrains Mono', monospace",
}}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr
key={i}
className="transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
}}
>
<div style={{ maxHeight: 280, overflowY: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "var(--text-xs)",
}}
>
<thead>
<tr>
{result.columns.map((col) => (
<td
<th
key={col}
className="px-3 py-1.5"
style={{
color: "var(--forge-text)",
fontFamily: "'JetBrains Mono', monospace",
position: "sticky",
top: 0,
padding: "0.5rem 0.75rem",
fontWeight: 600,
textAlign: "left",
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
color: "var(--forge-accent)",
fontFamily: "var(--font-mono)",
whiteSpace: "nowrap",
}}
>
{row[col]}
</td>
{col}
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr
key={i}
style={{
borderBottom: "1px solid var(--forge-border)",
}}
>
{result.columns.map((col) => (
<td
key={col}
style={{
padding: "0.4rem 0.75rem",
color: "var(--forge-text)",
fontFamily: "var(--font-mono)",
}}
>
{row[col]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<div
className="px-3 py-1 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
padding: "0.4rem 1rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
borderTop: "1px solid var(--forge-border)",
}}
>
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
</div>
@@ -395,11 +647,45 @@ function SQLConsole() {
export function Server() {
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Server Management
</h2>
<div className="flex flex-col gap-6">
<div
style={{
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-accent)",
margin: 0,
}}
>
Server Management
</h2>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.25rem 0 0",
fontFamily: "var(--font-sans)",
}}
>
Control server processes, view logs, and query the database
</p>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<ControlsPanel />
<LogViewer />
<SQLConsole />
+264 -135
View File
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useTheme } from "../hooks/useTheme";
import {
Key,
Sun,
Moon,
FolderOpen,
Container,
Keyboard,
Info,
RotateCcw,
Download,
} from "lucide-react";
const sectionCard: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
};
const sectionTitle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
margin: "0 0 1rem 0",
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const fieldLabel: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 500,
color: "var(--forge-text-secondary)",
margin: "0 0 0.25rem 0",
};
const fieldValue: React.CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
margin: 0,
};
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.4rem 0.875rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
background: "none",
border: "none",
color: "var(--forge-text-secondary)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
padding: "0.4rem 0.625rem",
borderRadius: "0.375rem",
};
const listRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.625rem 0.875rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
};
function Section({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
className="rounded-lg p-5"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="mb-4 text-sm font-semibold" style={{ color: "var(--forge-accent)" }}>
{title}
</h3>
<div style={sectionCard}>
<h3 style={sectionTitle}>{icon} {title}</h3>
{children}
</div>
);
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
const currentPat = (config.githubPat as string) || "";
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set";
const hasPat = Boolean(config.githubPat && config.githubPat !== "***");
const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
const save = async () => {
setSaving(true);
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
};
return (
<Section title="GitHub">
<div className="space-y-3">
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Personal Access Token
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{masked}
</p>
</div>
<Section title="Gitea Token" icon={<Key size={14} />}>
<div>
<p style={fieldLabel}>Personal Access Token</p>
<p style={fieldValue}>{masked}</p>
</div>
<div style={{ marginTop: "0.75rem" }}>
{!editing ? (
<button
onClick={() => setEditing(true)}
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Update PAT
<button onClick={() => setEditing(true)} style={primaryBtn}>
Update Token
</button>
) : (
<div className="flex gap-2">
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input
type="password"
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder="ghp_..."
className="flex-1 rounded px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
placeholder="Paste token"
style={{ flex: 1 }}
/>
<button
onClick={save}
disabled={!pat || saving}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
>
{saving ? "..." : "Save"}
{saving ? "Saving\u2026" : "Save"}
</button>
<button
onClick={() => {
setEditing(false);
setPat("");
}}
className="rounded px-3 py-1.5 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
<button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
Cancel
</button>
</div>
)}
{msg && (
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</div>
{msg && (
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</Section>
);
}
@@ -118,16 +167,17 @@ function ThemeSection() {
const { theme, toggleTheme } = useTheme();
return (
<Section title="Theme">
<div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
{theme === "dark" ? "Dark" : "Light"} Mode
</span>
<button
onClick={toggleTheme}
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
<Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
{theme === "dark" ? "Dark" : "Light"} Mode
</p>
<p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
</p>
</div>
<button onClick={toggleTheme} style={primaryBtn}>
Switch to {theme === "dark" ? "Light" : "Dark"}
</button>
</div>
@@ -135,25 +185,100 @@ function ThemeSection() {
);
}
function PathsSection({ config }: { config: Record<string, unknown> }) {
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<Section title="Paths">
<div className="space-y-3">
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
overflow: "hidden",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "2.5rem",
alignSelf: "stretch",
backgroundColor: "var(--forge-surface-raised)",
borderRight: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
flexShrink: 0,
}}
>
<FolderOpen size={14} />
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
border: "none",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
padding: "0.5rem 0.75rem",
color: "var(--forge-text)",
outline: "none",
}}
/>
</div>
);
}
function PathsSection({ config, onUpdate }: { config: Record<string, unknown>; onUpdate: () => void }) {
const [wsPath, setWsPath] = useState((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
const [nwnPath, setNwnPath] = useState((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
setWsPath((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
setNwnPath((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
}, [config]);
const save = async () => {
setSaving(true);
setMsg("");
try {
await api.workspace.updateConfig({ workspacePath: wsPath, nwnHomePath: nwnPath });
setMsg("Paths saved");
onUpdate();
} catch (err) {
setMsg(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
return (
<Section title="Paths" icon={<FolderOpen size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Workspace Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.WORKSPACE_PATH as string) || "Not set"}
</p>
<p style={fieldLabel}>Workspace Path</p>
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</div>
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
NWN Home Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.NWN_HOME_PATH as string) || "Not set"}
</p>
<p style={fieldLabel}>NWN Home Path</p>
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<button
onClick={save}
disabled={saving}
style={{ ...primaryBtn, opacity: saving ? 0.4 : 1 }}
>
{saving ? "Saving\u2026" : "Save Paths"}
</button>
{msg && (
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>{msg}</span>
)}
</div>
</div>
</Section>
@@ -172,40 +297,31 @@ function DockerSection() {
await api.docker.pull(image);
setStatus((s) => ({ ...s, [image]: "Pulled" }));
} catch (err) {
setStatus((s) => ({
...s,
[image]: err instanceof Error ? err.message : "Failed",
}));
setStatus((s) => ({ ...s, [image]: err instanceof Error ? err.message : "Failed" }));
} finally {
setPulling((s) => ({ ...s, [image]: false }));
}
};
return (
<Section title="Docker Images">
<div className="space-y-2">
<Section title="Docker Images" icon={<Container size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{images.map((image) => (
<div
key={image}
className="flex items-center justify-between rounded p-3"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image}
</span>
<div className="flex items-center gap-2">
<div key={image} style={listRow}>
<span style={fieldValue}>{image}</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{status[image] && (
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{status[image]}
</span>
)}
<button
onClick={() => pull(image)}
disabled={pulling[image]}
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
>
{pulling[image] ? "..." : "Pull Latest"}
<Download size={12} />
{pulling[image] ? "Pulling\u2026" : "Pull"}
</button>
</div>
</div>
@@ -225,21 +341,20 @@ const SHORTCUTS = [
function ShortcutsSection() {
return (
<Section title="Keyboard Shortcuts">
<div className="space-y-1">
<Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{SHORTCUTS.map((s) => (
<div
key={s.keys}
className="flex items-center justify-between rounded px-3 py-2"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
<div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{s.action}
</span>
<kbd
className="rounded px-2 py-0.5 font-mono text-xs"
style={{
backgroundColor: "var(--forge-surface)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
@@ -255,11 +370,11 @@ function ShortcutsSection() {
function AboutSection() {
return (
<Section title="About">
<p className="text-sm" style={{ color: "var(--forge-text)" }}>
<Section title="About" icon={<Info size={14} />}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Layonara Forge v0.0.1
</p>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
github.com/Layonara/layonara-forge
</p>
</Section>
@@ -270,6 +385,7 @@ function ResetSection() {
const navigate = useNavigate();
const reset = async () => {
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
try {
await api.workspace.updateConfig({ setupComplete: false });
} catch {
@@ -279,14 +395,28 @@ function ResetSection() {
};
return (
<Section title="Reset">
<button
onClick={reset}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }}
>
Re-run Setup Wizard
</button>
<Section title="Reset" icon={<RotateCcw size={14} />}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Clear configuration and re-run the setup wizard
</p>
<button
onClick={reset}
style={{
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
border: "1px solid var(--forge-danger-border)",
borderRadius: "0.375rem",
padding: "0.4rem 0.875rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
}}
>
Reset Setup
</button>
</div>
</Section>
);
}
@@ -299,24 +429,23 @@ export function Settings() {
}, []);
return (
<div className="h-full overflow-y-auto p-6">
<h2
className="mb-6 text-xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Settings
</h2>
<div className="max-w-2xl space-y-4">
<GitHubSection config={config} />
<ThemeSection />
<PathsSection config={config} />
<DockerSection />
<ShortcutsSection />
<AboutSection />
<ResetSection />
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "40rem" }}>
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
Settings
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Configuration, theme, and environment
</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<GitHubSection config={config} />
<ThemeSection />
<PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
<DockerSection />
<ShortcutsSection />
<AboutSection />
<ResetSection />
</div>
</div>
</div>
);
+452 -260
View File
@@ -5,9 +5,9 @@ import { api } from "../services/api";
const STEP_NAMES = [
"Welcome",
"Prerequisites",
"Gitea Token",
"Workspace",
"NWN Home",
"Gitea Token",
"Fork Repos",
"Clone Repos",
"Pull Images",
@@ -15,43 +15,144 @@ const STEP_NAMES = [
"Complete",
];
function StepIndicator({ current }: { current: number }) {
const PHASES = [
{ label: "Environment", icon: "\u2699", steps: [0, 1, 2, 3] },
{ label: "Authentication", icon: "\u26BF", steps: [4] },
{ label: "Repositories", icon: "\u2387", steps: [5, 6, 7] },
{ label: "Finalize", icon: "\u2692", steps: [8, 9] },
];
function getPhaseIndex(step: number): number {
return PHASES.findIndex((p) => p.steps.includes(step));
}
function PhaseIndicator({ current }: { current: number }) {
const currentPhase = getPhaseIndex(current);
const isLast = (i: number) => i === PHASES.length - 1;
return (
<div className="mb-2">
<div className="flex items-center justify-center gap-1">
{STEP_NAMES.map((name, i) => (
<div key={name} className="flex items-center gap-1">
<div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor:
i < current ? "var(--forge-accent)" : i === current ? "var(--forge-accent)" : "var(--forge-surface)",
color: i <= current ? "#000" : "var(--forge-text-secondary)",
border: i === current ? "2px solid var(--forge-accent)" : "1px solid var(--forge-border)",
opacity: i < current ? 0.7 : 1,
}}
title={name}
<nav aria-label="Setup progress" style={{ paddingTop: "1.25rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--forge-border)", marginBottom: "1.5rem" }}>
<ol style={{ display: "flex", alignItems: "center", width: "100%", listStyle: "none", margin: 0, padding: 0 }}>
{PHASES.map((phase, i) => {
const isComplete = i < currentPhase;
const isCurrent = i === currentPhase;
return (
<li
key={phase.label}
style={{ display: "flex", alignItems: "center", flex: isLast(i) ? "none" : 1 }}
aria-current={isCurrent ? "step" : undefined}
>
{i < current ? "\u2713" : i + 1}
</div>
{i < STEP_NAMES.length - 1 && (
<div
className="h-px w-3"
style={{
backgroundColor: i < current ? "var(--forge-accent)" : "var(--forge-border)",
}}
/>
)}
</div>
))}
</div>
<p className="mt-3 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Step {current + 1} of {STEP_NAMES.length} &mdash; {STEP_NAMES[current]}
</p>
</div>
<div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
<div
style={{
width: "2rem",
height: "2rem",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.8125rem",
fontWeight: 600,
flexShrink: 0,
backgroundColor: isComplete
? "var(--forge-success)"
: isCurrent
? "var(--forge-accent)"
: "var(--forge-surface-raised)",
color: isComplete || isCurrent
? "var(--forge-accent-text)"
: "var(--forge-text-secondary)",
}}
>
{isComplete ? "\u2713" : i + 1}
</div>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: isCurrent ? 600 : 400,
whiteSpace: "nowrap",
color: isCurrent
? "var(--forge-text)"
: isComplete
? "var(--forge-text)"
: "var(--forge-text-secondary)",
}}
>
{phase.label}
</span>
</div>
{!isLast(i) && (
<div
style={{
flex: 1,
height: "2px",
marginLeft: "0.75rem",
marginRight: "0.75rem",
minWidth: "1rem",
borderRadius: "1px",
backgroundColor: isComplete
? "var(--forge-success)"
: "var(--forge-border)",
}}
/>
)}
</div>
</li>
);
})}
</ol>
</nav>
);
}
function StatusDot({ status }: { status: "idle" | "working" | "ok" | "error" }) {
const base: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "1.25rem",
height: "1.25rem",
borderRadius: "50%",
fontSize: "0.625rem",
fontWeight: 700,
flexShrink: 0,
lineHeight: 1,
};
if (status === "working") {
return (
<span
style={{
...base,
border: "2px solid var(--forge-accent)",
borderTopColor: "transparent",
animation: "spin 0.8s linear infinite",
}}
/>
);
}
if (status === "ok") {
return (
<span style={{ ...base, backgroundColor: "var(--forge-success)", color: "var(--forge-accent-text)" }}>
&#x2713;
</span>
);
}
if (status === "error") {
return (
<span style={{ ...base, backgroundColor: "var(--forge-danger)", color: "var(--forge-accent-text)" }}>
&#x2717;
</span>
);
}
return null;
}
function StepNav({
onNext,
onBack,
@@ -66,25 +167,46 @@ function StepNav({
nextDisabled?: boolean;
}) {
return (
<div className="mt-6 flex justify-between">
{step > 0 ? (
<button
onClick={onBack}
className="rounded px-4 py-2 text-sm transition-colors hover:bg-white/10"
style={{ border: "1px solid var(--forge-border)", color: "var(--forge-text-secondary)" }}
>
Back
</button>
) : (
<div />
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "1.5rem", marginTop: "2rem", borderTop: "1px solid var(--forge-border)" }}>
<div>
{step > 0 && (
<button
onClick={onBack}
style={{
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
fontSize: "var(--text-sm)",
fontWeight: 500,
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--forge-text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--forge-text-secondary)"; }}
>
&larr; Back
</button>
)}
</div>
<button
onClick={onNext}
disabled={nextDisabled}
className="rounded px-4 py-2 text-sm font-semibold transition-colors disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.625rem 1.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: nextDisabled ? "not-allowed" : "pointer",
opacity: nextDisabled ? 0.4 : 1,
}}
onMouseEnter={(e) => { if (!nextDisabled) e.currentTarget.style.backgroundColor = "var(--forge-accent-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent)"; }}
>
{nextLabel}
{nextLabel} &rarr;
</button>
</div>
);
@@ -92,10 +214,10 @@ function StepNav({
function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) {
return (
<div className="mt-4 rounded p-3 text-sm" style={{ backgroundColor: "#3b1111", border: "1px solid #7f1d1d" }}>
<p style={{ color: "#fca5a5" }}>{error}</p>
<div className="mt-4 rounded p-3 text-sm" style={{ backgroundColor: "var(--forge-danger-bg)", border: "1px solid var(--forge-danger-border)" }}>
<p style={{ color: "var(--forge-danger)" }}>{error}</p>
{onRetry && (
<button onClick={onRetry} className="mt-2 text-xs underline" style={{ color: "#fca5a5" }}>
<button onClick={onRetry} className="mt-2 text-xs underline" style={{ color: "var(--forge-danger)" }}>
Retry
</button>
)}
@@ -103,6 +225,50 @@ function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) {
);
}
const stepHeading: React.CSSProperties = {
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
};
const stepDesc: React.CSSProperties = {
marginTop: "0.5rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
lineHeight: 1.6,
};
const fieldLabel: React.CSSProperties = {
display: "block",
fontSize: "var(--text-xs)",
fontWeight: 500,
color: "var(--forge-text-secondary)",
marginBottom: "0.375rem",
};
const statusRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1.25rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
};
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.5rem 1.25rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
};
interface StepProps {
onNext: () => void;
onBack: () => void;
@@ -110,24 +276,39 @@ interface StepProps {
function WelcomeStep({ onNext }: StepProps) {
return (
<div className="text-center">
<div style={{ padding: "2rem 0" }}>
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }}
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Welcome to Layonara Forge
</h2>
<p className="mt-4" style={{ color: "var(--forge-text-secondary)" }}>
This wizard will walk you through setting up your local NWN development environment &mdash; Docker, Gitea
access, workspace initialization, repository cloning, and database seeding.
<p style={{ marginTop: "0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", maxWidth: "40rem", lineHeight: 1.6 }}>
This wizard will walk you through setting up your local NWN development environment &mdash; Docker,
Gitea access, workspace initialization, repository cloning, and database seeding.
</p>
<div className="mt-8">
<div style={{ marginTop: "2rem" }}>
<button
onClick={onNext}
className="rounded px-6 py-2.5 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.625rem 1.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent)"; }}
>
Get Started
Get Started &rarr;
</button>
</div>
</div>
@@ -156,21 +337,17 @@ function PrerequisitesStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Prerequisites
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Checking that Docker and the Forge backend are running.
</p>
<div className="mt-4 flex items-center gap-3 rounded p-4" style={{ backgroundColor: "var(--forge-bg)" }}>
<span className="text-xl">
{status === "checking" || status === "idle" ? "\u23F3" : status === "ok" ? "\u2705" : "\u274C"}
</span>
<h2 style={stepHeading}>Prerequisites</h2>
<p style={stepDesc}>Checking that Docker and the Forge backend are running.</p>
<div style={{ ...statusRow, marginTop: "1.5rem" }}>
<StatusDot status={status === "checking" || status === "idle" ? "working" : status === "ok" ? "ok" : "error"} />
<div>
<p style={{ color: "var(--forge-text)" }}>Docker &amp; Backend</p>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Docker &amp; Backend
</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{status === "checking" || status === "idle"
? "Checking..."
? "Checking\u2026"
: status === "ok"
? "Backend is healthy"
: "Backend unreachable"}
@@ -205,67 +382,113 @@ function GiteaTokenStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Gitea Access Token
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<h2 style={stepHeading}>Gitea Access Token</h2>
<p style={stepDesc}>
A Gitea token is needed to fork and push to Layonara repositories. Generate one at{" "}
<a
href="https://gitea.layonara.com/user/settings/applications"
target="_blank"
rel="noreferrer"
style={{ color: "var(--forge-accent)" }}
>
gitea.layonara.com/user/settings/applications
<a href="https://gitea.layonara.com/user/settings/applications" target="_blank" rel="noreferrer" style={{ color: "var(--forge-accent)" }}>
gitea.layonara.com
</a>
</p>
<div className="mt-4 flex gap-2">
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your Gitea token"
className="flex-1 rounded px-3 py-2 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<button
onClick={validate}
disabled={!token || loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{loading ? "..." : "Validate"}
</button>
<div style={{ marginTop: "1.5rem" }}>
<label style={fieldLabel}>Access Token</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your Gitea token"
style={{ flex: 1 }}
/>
<button
onClick={validate}
disabled={!token || loading}
style={{ ...primaryBtn, opacity: !token || loading ? 0.4 : 1, cursor: !token || loading ? "not-allowed" : "pointer" }}
>
{loading ? "Validating\u2026" : "Validate"}
</button>
</div>
</div>
{username && (
<p className="mt-3 text-sm" style={{ color: "#4ade80" }}>
{"\u2705"} Authenticated as <strong>{username}</strong>
</p>
<div style={{ ...statusRow, marginTop: "1rem" }}>
<StatusDot status="ok" />
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-success)" }}>
Authenticated as <strong>{username}</strong>
</span>
</div>
)}
{error && <ErrorBox error={error} />}
<StepNav onNext={onNext} onBack={onBack} step={2} nextDisabled={!username} />
<StepNav onNext={onNext} onBack={onBack} step={4} nextDisabled={!username} />
</div>
);
}
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
overflow: "hidden",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "2.5rem",
alignSelf: "stretch",
backgroundColor: "var(--forge-surface-raised)",
borderRight: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
fontSize: "1rem",
flexShrink: 0,
}}
>
&#x1F4C1;
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
border: "none",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
padding: "0.625rem 0.75rem",
color: "var(--forge-text)",
outline: "none",
}}
/>
</div>
);
}
function WorkspaceStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({});
const [wsPath, setWsPath] = useState("");
const [initialized, setInitialized] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
api.workspace.getConfig().then((c) => {
setConfig(c);
setWsPath((c.WORKSPACE_PATH as string) || "/workspace");
}).catch(() => {});
}, []);
const init = async () => {
setLoading(true);
setError("");
try {
if (wsPath) await api.workspace.updateConfig({ WORKSPACE_PATH: wsPath });
await api.workspace.init();
setInitialized(true);
} catch (err) {
@@ -277,60 +500,58 @@ function WorkspaceStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Workspace
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Initialize the Forge workspace directory structure.
</p>
<div className="mt-4 rounded p-4" style={{ backgroundColor: "var(--forge-bg)" }}>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Workspace Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.WORKSPACE_PATH as string) || "/home/jmg/dev/layonara"}
</p>
<h2 style={stepHeading}>Workspace</h2>
<p style={stepDesc}>Choose the directory where Forge stores repos, server data, and configuration.</p>
<div style={{ marginTop: "1.5rem" }}>
<label style={fieldLabel}>Workspace Path</label>
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</div>
<div className="mt-4">
<div style={{ marginTop: "1.25rem" }}>
<button
onClick={init}
disabled={loading || initialized}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
disabled={loading || initialized || !wsPath}
style={{ ...primaryBtn, opacity: loading || initialized || !wsPath ? 0.5 : 1, cursor: loading || initialized || !wsPath ? "not-allowed" : "pointer" }}
>
{initialized ? "\u2705 Initialized" : loading ? "Initializing..." : "Initialize"}
{initialized ? "\u2713 Initialized" : loading ? "Initializing\u2026" : "Initialize"}
</button>
</div>
{error && <ErrorBox error={error} onRetry={init} />}
<StepNav onNext={onNext} onBack={onBack} step={3} />
<StepNav onNext={onNext} onBack={onBack} step={2} />
</div>
);
}
function NwnHomeStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({});
const [nwnPath, setNwnPath] = useState("");
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
api.workspace.getConfig().then((c) => {
setConfig(c);
setNwnPath((c.NWN_HOME_PATH as string) || "/nwn-home");
}).catch(() => {});
}, []);
const handleNext = async () => {
if (nwnPath) {
try {
await api.workspace.updateConfig({ NWN_HOME_PATH: nwnPath });
} catch {
// continue even if save fails
}
}
onNext();
};
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
NWN Home Directory
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/).
</p>
<div className="mt-4 rounded p-4" style={{ backgroundColor: "var(--forge-bg)" }}>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
NWN Home Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.NWN_HOME_PATH as string) || "/home/jmg/dev/nwn/local-server/home"}
</p>
<h2 style={stepHeading}>NWN Home Directory</h2>
<p style={stepDesc}>Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/).</p>
<div style={{ marginTop: "1.5rem" }}>
<label style={fieldLabel}>NWN Home Path</label>
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</div>
<StepNav onNext={onNext} onBack={onBack} step={4} />
<StepNav onNext={handleNext} onBack={onBack} step={3} />
</div>
);
}
@@ -365,25 +586,17 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Fork Repositories
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Fork the Layonara repositories to your Gitea account.
</p>
<div className="mt-4 space-y-3">
<h2 style={stepHeading}>Fork Repositories</h2>
<p style={stepDesc}>Fork the Layonara repositories to your Gitea account.</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{forkableRepos.map((repo) => (
<div
key={repo}
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div key={repo} style={{ ...statusRow, justifyContent: "space-between" }}>
<div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
layonara/{repo}
</p>
{errors[repo] && (
<p className="mt-1 text-xs" style={{ color: "#fca5a5" }}>
<p className="mt-1 text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]}
</p>
)}
@@ -391,21 +604,17 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
<button
onClick={() => forkRepo(repo)}
disabled={forkStatus[repo] === "forked" || forkStatus[repo] === "forking"}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, fontSize: "var(--text-xs)", padding: "0.375rem 0.875rem", opacity: forkStatus[repo] === "forked" || forkStatus[repo] === "forking" ? 0.5 : 1 }}
>
{forkStatus[repo] === "forked"
? "\u2705 Forked"
? "\u2713 Forked"
: forkStatus[repo] === "forking"
? "..."
? "Forking\u2026"
: "Fork"}
</button>
</div>
))}
<div
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div style={{ ...statusRow, justifyContent: "space-between" }}>
<div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
plenarius/unified
@@ -450,25 +659,19 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Clone Repositories
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Clone all repositories into the workspace. Gitea repos clone from your fork; unified clones directly from
GitHub (public, read-only).
<h2 style={stepHeading}>Clone Repositories</h2>
<p style={stepDesc}>
Clone all repositories into the workspace. Gitea repos use your fork; unified clones from GitHub.
</p>
<div className="mt-4 space-y-2">
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{repos.map((repo) => (
<div key={repo} className="flex items-center gap-3 rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
<span>
{cloneStatus[repo] === "cloned"
? "\u2705"
: cloneStatus[repo] === "cloning"
? "\u23F3"
: cloneStatus[repo] === "error"
? "\u274C"
: "\u25CB"}
</span>
<div key={repo} style={{ ...statusRow }}>
<StatusDot status={
cloneStatus[repo] === "cloned" ? "ok"
: cloneStatus[repo] === "cloning" ? "working"
: cloneStatus[repo] === "error" ? "error"
: "idle"
} />
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{repo}
</span>
@@ -478,21 +681,20 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
</span>
)}
{errors[repo] && (
<span className="text-xs" style={{ color: "#fca5a5" }}>
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]}
</span>
)}
</div>
))}
</div>
<div className="mt-4">
<div style={{ marginTop: "1.25rem" }}>
<button
onClick={cloneAll}
disabled={cloning || allDone}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: cloning || allDone ? 0.5 : 1, cursor: cloning || allDone ? "not-allowed" : "pointer" }}
>
{allDone ? "\u2705 All Cloned" : cloning ? "Cloning..." : "Clone All"}
{allDone ? "\u2713 All Cloned" : cloning ? "Cloning\u2026" : "Clone All"}
</button>
</div>
<StepNav onNext={onNext} onBack={onBack} step={6} />
@@ -526,43 +728,35 @@ function PullImagesStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Pull Docker Images
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Pull the required Docker images for the local dev environment.
</p>
<div className="mt-4 space-y-2">
<h2 style={stepHeading}>Pull Docker Images</h2>
<p style={stepDesc}>Pull the required Docker images for the local dev environment.</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{images.map((image) => (
<div key={image} className="flex items-center gap-3 rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
<span>
{pullStatus[image] === "pulled"
? "\u2705"
: pullStatus[image] === "pulling"
? "\u23F3"
: pullStatus[image] === "error"
? "\u274C"
: "\u25CB"}
</span>
<div key={image} style={{ ...statusRow }}>
<StatusDot status={
pullStatus[image] === "pulled" ? "ok"
: pullStatus[image] === "pulling" ? "working"
: pullStatus[image] === "error" ? "error"
: "idle"
} />
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image}
</span>
{errors[image] && (
<span className="text-xs" style={{ color: "#fca5a5" }}>
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[image]}
</span>
)}
</div>
))}
</div>
<div className="mt-4">
<div style={{ marginTop: "1.25rem" }}>
<button
onClick={pullAll}
disabled={pulling || allDone}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: pulling || allDone ? 0.5 : 1, cursor: pulling || allDone ? "not-allowed" : "pointer" }}
>
{allDone ? "\u2705 All Pulled" : pulling ? "Pulling..." : "Pull Images"}
{allDone ? "\u2713 All Pulled" : pulling ? "Pulling\u2026" : "Pull Images"}
</button>
</div>
<StepNav onNext={onNext} onBack={onBack} step={7} />
@@ -592,56 +786,37 @@ function SeedDbStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Seed Database
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Add your NWN CD key and player name so the dev server recognizes you as a DM.
</p>
<div className="mt-4 space-y-3">
<h2 style={stepHeading}>Seed Database</h2>
<p style={stepDesc}>Add your NWN CD key and player name so the dev server recognizes you as a DM.</p>
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}>
CD Key
</label>
<label style={fieldLabel}>CD Key</label>
<input
type="text"
value={cdKey}
onChange={(e) => setCdKey(e.target.value.toUpperCase())}
placeholder="e.g. UPQNKG4R"
className="mt-1 w-full rounded px-3 py-2 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
style={{ width: "100%" }}
/>
</div>
<div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Player Name
</label>
<label style={fieldLabel}>Player Name</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="e.g. contributor"
className="mt-1 w-full rounded px-3 py-2 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
style={{ width: "100%" }}
/>
</div>
</div>
<div className="mt-4">
<div style={{ marginTop: "1rem" }}>
<button
onClick={seed}
disabled={!cdKey || !playerName || loading || seeded}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ ...primaryBtn, opacity: !cdKey || !playerName || loading || seeded ? 0.5 : 1, cursor: !cdKey || !playerName || loading || seeded ? "not-allowed" : "pointer" }}
>
{seeded ? "\u2705 Seeded" : loading ? "Seeding..." : "Seed"}
{seeded ? "\u2713 Seeded" : loading ? "Seeding\u2026" : "Seed Database"}
</button>
</div>
{error && <ErrorBox error={error} onRetry={seed} />}
@@ -652,24 +827,39 @@ function SeedDbStep({ onNext, onBack }: StepProps) {
function CompleteStep({ onFinish }: { onFinish: () => void }) {
return (
<div className="text-center">
<div style={{ padding: "2rem 0" }}>
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }}
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Setup Complete!
Setup Complete
</h2>
<p className="mt-4" style={{ color: "var(--forge-text-secondary)" }}>
Your Layonara Forge environment is ready. Build modules, edit scripts, manage repositories, and run the local
dev server.
<p style={{ marginTop: "0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", maxWidth: "40rem", lineHeight: 1.6 }}>
Your Layonara Forge environment is ready. Build modules, edit scripts, manage repositories, and run the
local dev server.
</p>
<div className="mt-8">
<div style={{ marginTop: "2rem" }}>
<button
onClick={onFinish}
className="rounded px-6 py-2.5 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.625rem 1.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent)"; }}
>
Open Forge
Open Forge &rarr;
</button>
</div>
</div>
@@ -695,9 +885,9 @@ export function Setup() {
const stepComponents = [
<WelcomeStep key="welcome" onNext={next} onBack={back} />,
<PrerequisitesStep key="prereqs" onNext={next} onBack={back} />,
<GiteaTokenStep key="token" onNext={next} onBack={back} />,
<WorkspaceStep key="workspace" onNext={next} onBack={back} />,
<NwnHomeStep key="nwnhome" onNext={next} onBack={back} />,
<GiteaTokenStep key="token" onNext={next} onBack={back} />,
<ForkReposStep key="fork" onNext={next} onBack={back} />,
<CloneReposStep key="clone" onNext={next} onBack={back} />,
<PullImagesStep key="pull" onNext={next} onBack={back} />,
@@ -706,14 +896,16 @@ export function Setup() {
];
return (
<>
<StepIndicator current={step} />
<div
className="mt-4 rounded-lg p-8"
style={{ backgroundColor: "var(--forge-surface)", border: "1px solid var(--forge-border)" }}
>
{stepComponents[step]}
</div>
</>
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "0 2.5rem 2.5rem",
}}
>
<PhaseIndicator current={step} />
{stepComponents[step]}
</div>
);
}
+528 -212
View File
@@ -2,6 +2,16 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Eye,
EyeOff,
FileCode,
Check,
X,
RefreshCw,
Trash2,
ArrowUpCircle,
} from "lucide-react";
interface ChangeEntry {
filename: string;
@@ -16,61 +26,6 @@ interface DiffData {
filename: string;
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
active
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{active ? "Active" : "Inactive"}
</span>
);
}
function ActionButton({
label,
onClick,
disabled,
variant = "default",
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "danger";
}) {
const styles = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
},
danger: {
backgroundColor: "#7f1d1d",
borderColor: "#991b1b",
color: "#fca5a5",
},
};
return (
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
>
{label}
</button>
);
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString();
}
@@ -222,217 +177,578 @@ export function Toolset() {
};
const handleDiscardAll = async () => {
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
await api.toolset.discardAll();
refresh();
};
return (
<div
className="flex h-full flex-col overflow-hidden"
style={{ color: "var(--forge-text)" }}
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
color: "var(--forge-text)",
}}
>
{/* Status bar */}
<div
className="flex shrink-0 items-center justify-between px-6 py-3"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<div className="flex items-center gap-4">
<h2
className="text-xl font-bold"
style={{ color: "var(--forge-accent)" }}
{/* Page heading */}
<div style={{ padding: "24px 28px 0" }}>
<h1
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Toolset
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "4px 0 0",
}}
>
Watch for NWN Toolset changes and apply them to the repository
</p>
</div>
{/* Watcher status card */}
<div style={{ padding: "16px 28px" }}>
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
padding: "16px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 16 }}
>
Toolset
</h2>
<StatusBadge active={active} />
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{changes.length} pending
</span>
{lastChange && (
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
Last: {formatTimestamp(lastChange)}
{active ? (
<Eye size={16} style={{ color: "var(--forge-success)" }} />
) : (
<EyeOff
size={16}
style={{ color: "var(--forge-text-secondary)" }}
/>
)}
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "3px 10px",
borderRadius: 999,
fontSize: "var(--text-xs)",
fontWeight: 600,
backgroundColor: active
? "var(--forge-success-bg)"
: "var(--forge-surface-raised)",
color: active
? "var(--forge-success)"
: "var(--forge-text-secondary)",
}}
>
{active ? "Active" : "Inactive"}
</span>
</div>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{changes.length} pending change{changes.length !== 1 && "s"}
</span>
)}
</div>
<div className="flex gap-2">
{active ? (
<ActionButton label="Stop Watcher" onClick={handleStop} />
) : (
<ActionButton
label="Start Watcher"
onClick={handleStart}
variant="primary"
/>
)}
{lastChange && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Last change: {formatTimestamp(lastChange)}
</span>
)}
</div>
<button
onClick={active ? handleStop : handleStart}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 14px",
borderRadius: 6,
border: "1px solid",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: active
? "var(--forge-surface)"
: "var(--forge-accent)",
borderColor: active
? "var(--forge-border)"
: "var(--forge-accent)",
color: active
? "var(--forge-text)"
: "var(--forge-accent-text)",
}}
>
{active ? (
<>
<EyeOff size={14} />
Stop Watcher
</>
) : (
<>
<Eye size={14} />
Start Watcher
</>
)}
</button>
</div>
</div>
{/* Action bar */}
{changes.length > 0 && (
{/* Main content area */}
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: "0 28px 20px",
}}
>
{/* Changes card */}
<div
className="flex shrink-0 items-center gap-2 px-6 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
display: "flex",
flexDirection: "column",
flex: diffData ? "0 0 auto" : 1,
maxHeight: diffData ? "40%" : undefined,
}}
>
<ActionButton
label="Apply Selected"
variant="primary"
disabled={selected.size === 0}
onClick={handleApplySelected}
/>
<ActionButton
label="Apply All"
variant="primary"
onClick={handleApplyAll}
/>
<ActionButton
label="Discard Selected"
variant="danger"
disabled={selected.size === 0}
onClick={handleDiscardSelected}
/>
<ActionButton
label="Discard All"
variant="danger"
onClick={handleDiscardAll}
/>
<span
className="ml-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
{/* Card header with action bar */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
{selected.size} selected
</span>
</div>
)}
{/* Main content: table + diff */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Changes table */}
<div
className="shrink-0 overflow-auto"
style={{ maxHeight: diffData ? "40%" : "100%" }}
>
{changes.length === 0 ? (
<div
className="flex h-40 items-center justify-center text-sm"
style={{ color: "var(--forge-text-secondary)" }}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
{active
? "Watching for changes in temp0/..."
: "Start the watcher to detect Toolset changes"}
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr
<FileCode
size={16}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
color: "var(--forge-text)",
}}
>
Pending Changes
</span>
{changes.length > 0 && (
<span
style={{
borderBottom: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
marginLeft: 4,
}}
>
<th className="px-6 py-2 text-left font-medium">
<input
type="checkbox"
checked={selected.size === changes.length}
onChange={toggleAll}
className="cursor-pointer"
{selected.size} of {changes.length} selected
</span>
)}
</div>
{changes.length > 0 && (
<div
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<button
onClick={handleApplySelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
}}
>
<Check size={12} />
Apply Selected
</button>
<button
onClick={handleApplyAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-accent)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-accent)",
}}
>
<ArrowUpCircle size={12} />
Apply All
</button>
<button
onClick={handleDiscardSelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
}}
>
<Trash2 size={12} />
Discard Selected
</button>
<button
onClick={handleDiscardAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-danger-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-danger)",
}}
>
<X size={12} />
Discard All
</button>
</div>
)}
</div>
{/* Table or empty state */}
<div style={{ overflow: "auto", flex: 1 }}>
{changes.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--forge-text-secondary)",
}}
>
{active ? (
<>
<RefreshCw
size={28}
style={{
marginBottom: 12,
opacity: 0.4,
animation: "spin 3s linear infinite",
}}
/>
</th>
<th className="px-2 py-2 text-left font-medium">Filename</th>
<th className="px-2 py-2 text-left font-medium">Type</th>
<th className="px-2 py-2 text-left font-medium">
Repo Path
</th>
<th className="px-2 py-2 text-left font-medium">Time</th>
</tr>
</thead>
<tbody>
{changes.map((change) => (
<span style={{ fontSize: "var(--text-sm)" }}>
Watching for changes in temp0/...
</span>
</>
) : (
<>
<EyeOff
size={28}
style={{ marginBottom: 12, opacity: 0.4 }}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Start the watcher to detect Toolset changes
</span>
</>
)}
</div>
) : (
<table
style={{
width: "100%",
fontSize: "var(--text-sm)",
borderCollapse: "collapse",
}}
>
<thead>
<tr
key={change.filename}
className="cursor-pointer transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-surface)"
: undefined,
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
}}
onClick={() => viewDiff(change)}
>
<td className="px-6 py-2">
<th
style={{
padding: "8px 16px",
textAlign: "left",
fontWeight: 500,
width: 40,
}}
>
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
checked={
selected.size === changes.length &&
changes.length > 0
}
onChange={toggleAll}
style={{ cursor: "pointer" }}
/>
</td>
<td className="px-2 py-2 font-mono">{change.filename}</td>
<td className="px-2 py-2">
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs">
{change.gffType}
</span>
</td>
<td
className="px-2 py-2 font-mono text-xs"
style={{ color: "var(--forge-text-secondary)" }}
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
{change.repoPath ?? "—"}
</td>
<td
className="px-2 py-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
Filename
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
{formatTimestamp(change.timestamp)}
</td>
Type
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Repo Path
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Time
</th>
</tr>
))}
</tbody>
</table>
)}
</thead>
<tbody>
{changes.map((change) => (
<tr
key={change.filename}
onClick={() => viewDiff(change)}
style={{
borderBottom: "1px solid var(--forge-border)",
cursor: "pointer",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-accent-subtle)"
: undefined,
}}
>
<td style={{ padding: "8px 16px" }}>
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
style={{ cursor: "pointer" }}
/>
</td>
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{change.filename}
</td>
<td style={{ padding: "8px 10px" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 4,
fontSize: "var(--text-xs)",
fontWeight: 500,
backgroundColor: "var(--forge-accent-subtle)",
color: "var(--forge-accent)",
}}
>
{change.gffType}
</span>
</td>
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{change.repoPath ?? "—"}
</td>
<td
style={{
padding: "8px 10px",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{formatTimestamp(change.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Diff panel */}
{/* Diff viewer panel */}
{diffData && (
<div
ref={diffContainerRef}
className="flex min-h-0 flex-1 flex-col"
style={{ borderTop: "1px solid var(--forge-border)" }}
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
marginTop: 16,
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* Diff header */}
<div
className="flex shrink-0 items-center justify-between px-4 py-1.5"
style={{
backgroundColor: "var(--forge-surface)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "var(--forge-surface-raised)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="text-xs font-medium">
Diff: {diffData.filename}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<FileCode
size={14}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{diffData.filename}
</span>
{loading && (
<span style={{ color: "var(--forge-text-secondary)" }}>
{" "}
(loading...)
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Loading...
</span>
)}
</span>
</div>
<button
onClick={() => setDiffData(null)}
className="text-xs transition-opacity hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderRadius: 5,
border: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
}}
>
<X size={12} />
Close
</button>
</div>
<div className="min-h-0 flex-1">
{/* Diff content */}
<div
style={{
flex: 1,
minHeight: 0,
backgroundColor: "var(--forge-log-bg)",
}}
>
<DiffEditor
original={diffData.original}
modified={diffData.modified}
+173 -13
View File
@@ -1,25 +1,185 @@
@import "tailwindcss";
@import "@fontsource-variable/manrope";
@import "@fontsource-variable/alegreya";
@import "@fontsource-variable/jetbrains-mono";
:root {
--forge-bg: #121212;
--forge-surface: #1e1e2e;
--forge-border: #2e2e3e;
--forge-accent: #946200;
--forge-text: #f2f2f2;
--forge-text-secondary: #888888;
--forge-bg: oklch(15% 0.01 65);
--forge-surface: oklch(20% 0.012 65);
--forge-surface-raised: oklch(24% 0.014 65);
--forge-border: oklch(30% 0.014 65);
--forge-accent: oklch(58% 0.155 65);
--forge-accent-hover: oklch(63% 0.16 65);
--forge-accent-subtle: oklch(25% 0.04 65);
--forge-accent-text: oklch(15% 0.03 65);
--forge-text: oklch(93% 0.006 65);
--forge-text-secondary: oklch(68% 0.01 65);
--forge-success: oklch(62% 0.14 150);
--forge-success-bg: oklch(22% 0.03 150);
--forge-success-border: oklch(35% 0.06 150);
--forge-danger: oklch(68% 0.14 25);
--forge-danger-bg: oklch(22% 0.04 25);
--forge-danger-border: oklch(35% 0.08 25);
--forge-danger-strong: oklch(55% 0.18 25);
--forge-warning: oklch(72% 0.14 80);
--forge-warning-bg: oklch(25% 0.04 80);
--forge-warning-border: oklch(40% 0.07 80);
--forge-info: oklch(62% 0.08 230);
--forge-info-bg: oklch(22% 0.02 230);
--forge-log-bg: oklch(13% 0.008 65);
--forge-log-text: oklch(82% 0.008 65);
--font-sans: "Manrope Variable", system-ui, sans-serif;
--font-heading: "Alegreya Variable", Georgia, serif;
--font-mono: "JetBrains Mono Variable", "Fira Code", monospace;
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1.0625rem;
--text-xl: 1.25rem;
--text-2xl: 1.75rem;
--leading-tight: 1.2;
--leading-normal: 1.55;
--leading-relaxed: 1.7;
}
:root.light {
--forge-bg: #f2f2f2;
--forge-surface: #ffffff;
--forge-border: #cbcbcb;
--forge-accent: #946200;
--forge-text: #252525;
--forge-text-secondary: #666666;
--forge-bg: oklch(95% 0.008 65);
--forge-surface: oklch(99% 0.004 65);
--forge-surface-raised: oklch(100% 0.002 65);
--forge-border: oklch(82% 0.012 65);
--forge-accent: oklch(50% 0.155 65);
--forge-accent-hover: oklch(45% 0.16 65);
--forge-accent-subtle: oklch(90% 0.04 65);
--forge-accent-text: oklch(99% 0.005 65);
--forge-text: oklch(20% 0.012 65);
--forge-text-secondary: oklch(45% 0.015 65);
--forge-success: oklch(45% 0.14 150);
--forge-success-bg: oklch(92% 0.03 150);
--forge-success-border: oklch(70% 0.08 150);
--forge-danger: oklch(50% 0.16 25);
--forge-danger-bg: oklch(92% 0.03 25);
--forge-danger-border: oklch(70% 0.08 25);
--forge-danger-strong: oklch(45% 0.18 25);
--forge-warning: oklch(55% 0.14 80);
--forge-warning-bg: oklch(92% 0.04 80);
--forge-warning-border: oklch(70% 0.07 80);
--forge-info: oklch(45% 0.08 230);
--forge-info-bg: oklch(92% 0.02 230);
--forge-log-bg: oklch(96% 0.006 65);
--forge-log-text: oklch(30% 0.01 65);
}
body {
background-color: var(--forge-bg);
color: var(--forge-text);
font-family: "Inter", system-ui, sans-serif;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
font-kerning: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.font-heading {
font-family: var(--font-heading);
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
::selection {
background-color: var(--forge-accent-subtle);
color: var(--forge-text);
}
:focus-visible {
outline: 2px solid var(--forge-accent);
outline-offset: 2px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--forge-border) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--forge-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--forge-text-secondary);
}
input[type="text"],
input[type="password"],
input[type="url"],
input[type="email"],
input[type="number"],
input[type="search"],
textarea,
select {
background-color: var(--forge-bg);
color: var(--forge-text);
border: 1px solid var(--forge-border);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
font-family: inherit;
transition: border-color 150ms ease-out;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--forge-accent);
}
input::placeholder,
textarea::placeholder {
color: var(--forge-text-secondary);
opacity: 0.6;
}
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
transition: background-color 150ms ease-out, color 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out;
}
button:disabled {
cursor: not-allowed;
}
a {
transition: color 150ms ease-out;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+29 -3
View File
@@ -9,16 +9,42 @@ export default {
forge: {
bg: "var(--forge-bg)",
surface: "var(--forge-surface)",
"surface-raised": "var(--forge-surface-raised)",
border: "var(--forge-border)",
accent: "var(--forge-accent)",
"accent-hover": "var(--forge-accent-hover)",
"accent-subtle": "var(--forge-accent-subtle)",
"accent-text": "var(--forge-accent-text)",
text: "var(--forge-text)",
"text-secondary": "var(--forge-text-secondary)",
success: "var(--forge-success)",
"success-bg": "var(--forge-success-bg)",
"success-border": "var(--forge-success-border)",
danger: "var(--forge-danger)",
"danger-bg": "var(--forge-danger-bg)",
"danger-border": "var(--forge-danger-border)",
"danger-strong": "var(--forge-danger-strong)",
warning: "var(--forge-warning)",
"warning-bg": "var(--forge-warning-bg)",
"warning-border": "var(--forge-warning-border)",
info: "var(--forge-info)",
"info-bg": "var(--forge-info-bg)",
"log-bg": "var(--forge-log-bg)",
"log-text": "var(--forge-log-text)",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"],
serif: ["Baskerville", "Georgia", "Palatino", "serif"],
sans: ["Manrope Variable", "system-ui", "sans-serif"],
heading: ["Alegreya Variable", "Georgia", "serif"],
mono: ["JetBrains Mono Variable", "Fira Code", "monospace"],
},
fontSize: {
xs: "var(--text-xs)",
sm: "var(--text-sm)",
base: "var(--text-base)",
lg: "var(--text-lg)",
xl: "var(--text-xl)",
"2xl": "var(--text-2xl)",
},
},
},