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": ">=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": { "node_modules/@grpc/grpc-js": {
"version": "1.14.3", "version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
@@ -2922,6 +2949,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4896,9 +4932,13 @@
"name": "@layonara-forge/frontend", "name": "@layonara-forge/frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "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", "@monaco-editor/react": "^4.7.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layonara Forge</title> <title>Layonara Forge</title>
<link rel="icon" href="/favicon.ico" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4
View File
@@ -9,9 +9,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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", "@monaco-editor/react": "^4.7.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, lazy, Suspense } from "react";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor"; const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
import { Build } from "./pages/Build"; const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
import { Server } from "./pages/Server"; const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
import { Toolset } from "./pages/Toolset"; const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
import { Repos } from "./pages/Repos"; const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
import { Settings } from "./pages/Settings"; const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
import { Setup } from "./pages/Setup"; 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 { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout"; import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer"; import { FileExplorer } from "./components/editor/FileExplorer";
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module"; 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 }) { function SetupGuard({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false); const [needsSetup, setNeedsSetup] = useState(false);
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
.finally(() => setChecking(false)); .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 />; if (needsSetup) return <Navigate to="/setup" replace />;
return <>{children}</>; return <>{children}</>;
} }
@@ -73,29 +86,31 @@ export function App() {
<ToastProvider> <ToastProvider>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <BrowserRouter>
<Routes> <Suspense fallback={<PageLoader />}>
<Route path="/setup" element={<SetupLayout />}> <Routes>
<Route index element={<Setup />} /> <Route path="/setup" element={<SetupLayout />}>
</Route> <Route index element={<Setup />} />
<Route </Route>
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Route <Route
path="/editor" element={
element={<Editor editorState={editorState} />} <SetupGuard>
/> <IDELayout sidebar={sidebar} />
<Route path="build" element={<Build />} /> </SetupGuard>
<Route path="server" element={<Server />} /> }
<Route path="toolset" element={<Toolset />} /> >
<Route path="repos" element={<Repos />} /> <Route path="/" element={<Dashboard />} />
<Route path="settings" element={<Settings />} /> <Route
</Route> path="/editor"
</Routes> 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> </BrowserRouter>
</ErrorBoundary> </ErrorBoundary>
</ToastProvider> </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)" }} 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> <div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre> <pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
</div> </div>
{error && ( {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"> <div className="flex justify-end gap-2">
@@ -125,7 +125,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onClick={() => handleSubmit(false)} onClick={() => handleSubmit(false)}
disabled={!isValid || loading} disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" 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 Commit
</button> </button>
@@ -133,7 +133,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onClick={() => handleSubmit(true)} onClick={() => handleSubmit(true)}
disabled={!isValid || loading} disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" 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 Commit & Push
</button> </button>
@@ -25,10 +25,10 @@ export function ErrorDisplay({
className="rounded-lg p-6" className="rounded-lg p-6"
style={{ style={{
backgroundColor: "var(--forge-surface)", 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} {title}
</h3> </h3>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}> <p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
@@ -50,7 +50,7 @@ export function ErrorDisplay({
style={{ style={{
backgroundColor: "var(--forge-bg)", backgroundColor: "var(--forge-bg)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
}} }}
> >
{fullLog} {fullLog}
@@ -64,7 +64,7 @@ export function ErrorDisplay({
<button <button
onClick={onRetry} onClick={onRetry}
className="rounded px-4 py-2 text-sm font-semibold" 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 Retry
</button> </button>
+5 -5
View File
@@ -29,9 +29,9 @@ export function useToast() {
} }
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = { const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
success: { bg: "#052e16", border: "#166534", text: "#4ade80" }, success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" }, error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" }, info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
}; };
const AUTO_DISMISS: Record<ToastType, number | null> = { const AUTO_DISMISS: Record<ToastType, number | null> = {
@@ -49,7 +49,7 @@ function ToastItem({
}) { }) {
const { bg, border, text } = COLORS[toast.type]; const { bg, border, text } = COLORS[toast.type];
const timeout = AUTO_DISMISS[toast.type]; const timeout = AUTO_DISMISS[toast.type];
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
if (timeout) { if (timeout) {
@@ -92,7 +92,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return ( return (
<ToastContext.Provider value={{ showToast }}> <ToastContext.Provider value={{ showToast }}>
{children} {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) => ( {toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} /> <ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))} ))}
@@ -56,16 +56,18 @@ export function EditorTabs({
/> />
)} )}
</span> </span>
<span <button
type="button"
aria-label="Close tab"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClose(tab.path); onClose(tab.path);
}} }}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100" 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> </button>
); );
})} })}
@@ -87,7 +87,7 @@ function FileTreeNode({
paddingLeft: `${depth * 16 + 8}px`, paddingLeft: `${depth * 16 + 8}px`,
backgroundColor: isSelected ? "var(--forge-surface)" : undefined, backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)", color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "13px", fontSize: "13px",
}} }}
> >
@@ -186,8 +186,17 @@ export function FileExplorer({
)} )}
{error && ( {error && (
<div className="px-3 py-4 text-sm text-red-400"> <div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{error} {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> </div>
)} )}
@@ -167,13 +167,14 @@ function registerNWScript(monaco: Parameters<OnMount>[1]) {
function defineForgeTheme(monaco: Parameters<OnMount>[1]) { function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
const style = getComputedStyle(document.documentElement); const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212"; const bg = style.getPropertyValue("--forge-bg").trim() || "#1f1a14";
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e"; const surface = style.getPropertyValue("--forge-surface").trim() || "#2a2419";
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200"; const accent = style.getPropertyValue("--forge-accent").trim() || "#b07a1a";
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2"; const text = style.getPropertyValue("--forge-text").trim() || "#ede8e0";
const textSecondary = const textSecondary =
style.getPropertyValue("--forge-text-secondary").trim() || "#888888"; style.getPropertyValue("--forge-text-secondary").trim() || "#9a9080";
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e"; const border = style.getPropertyValue("--forge-border").trim() || "#3d3528";
const accentSubtle = style.getPropertyValue("--forge-accent-subtle").trim() || "#2e2818";
monaco.editor.defineTheme("forge-dark", { monaco.editor.defineTheme("forge-dark", {
base: "vs-dark", base: "vs-dark",
@@ -196,7 +197,7 @@ function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
"editor.lineHighlightBackground": surface, "editor.lineHighlightBackground": surface,
"editorLineNumber.foreground": textSecondary, "editorLineNumber.foreground": textSecondary,
"editorLineNumber.activeForeground": text, "editorLineNumber.activeForeground": text,
"editor.selectionBackground": "#264f7840", "editor.selectionBackground": accentSubtle,
"editorWidget.background": surface, "editorWidget.background": surface,
"editorWidget.border": border, "editorWidget.border": border,
"editorSuggestWidget.background": surface, "editorSuggestWidget.background": surface,
@@ -82,9 +82,9 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({ const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
backgroundColor: active ? "var(--forge-accent)" : "transparent", 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)"}`, border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "12px", fontSize: "12px",
lineHeight: "1", lineHeight: "1",
}); });
@@ -119,7 +119,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)", color: "var(--forge-text)",
border: "1px solid var(--forge-border)", border: "1px solid var(--forge-border)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "13px", 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" className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
style={{ style={{
backgroundColor: "var(--forge-accent)", backgroundColor: "var(--forge-accent)",
color: "#121212", color: "var(--forge-accent-text)",
}} }}
> >
{loading ? "Searching..." : "Search"} {loading ? "Searching..." : "Search"}
@@ -181,7 +181,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{error && ( {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 && ( {searched && !loading && !error && (
@@ -213,7 +213,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</span> </span>
<span <span
className="flex-1 truncate font-medium" className="flex-1 truncate font-medium"
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }} style={{ fontFamily: "var(--font-mono)", fontSize: "12px" }}
> >
{group.file} {group.file}
</span> </span>
@@ -240,7 +240,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
className="shrink-0 text-xs" className="shrink-0 text-xs"
style={{ style={{
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "11px", fontSize: "11px",
minWidth: "32px", minWidth: "32px",
textAlign: "right", textAlign: "right",
@@ -251,7 +251,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<span <span
className="truncate text-xs" className="truncate text-xs"
style={{ style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: "12px", fontSize: "12px",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
}} }}
@@ -129,8 +129,8 @@ function DialogNodeItem({
<span <span
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs" className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
style={{ style={{
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20", backgroundColor: type === "entry" ? "var(--forge-info-bg)" : "var(--forge-success-bg)",
color: type === "entry" ? "#60a5fa" : "#4ade80", color: type === "entry" ? "var(--forge-info)" : "var(--forge-success)",
}} }}
> >
{type === "entry" ? "E" : "R"}{index} {type === "entry" ? "E" : "R"}{index}
@@ -287,7 +287,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
onClick={handleSave} onClick={handleSave}
disabled={!dirty || saving} disabled={!dirty || saving}
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40" 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"} {saving ? "Saving..." : "Save"}
</button> </button>
@@ -317,7 +317,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{error && ( {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" && ( {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" className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{ style={{
backgroundColor: "var(--forge-bg)", backgroundColor: "var(--forge-bg)",
borderColor: valid ? "var(--forge-border)" : "#ef4444", borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)",
color: "var(--forge-text)", color: "var(--forge-text)",
}} }}
/> />
<span <span
className="text-xs" className="text-xs"
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }} style={{ color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)" }}
> >
{str.length}/16 {str.length}/16
</span> </span>
@@ -421,7 +421,7 @@ export function GffEditor({
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-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 && ( {onSwitchToRaw && (
<button <button
onClick={onSwitchToRaw} onClick={onSwitchToRaw}
@@ -461,7 +461,7 @@ export function GffEditor({
</span> </span>
)} )}
{error && ( {error && (
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span> <span className="text-xs" style={{ color: "var(--forge-danger)" }}>{error}</span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <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" className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{ style={{
backgroundColor: "var(--forge-accent)", backgroundColor: "var(--forge-accent)",
color: "#fff", color: "var(--forge-accent-text)",
}} }}
> >
{saving ? "Saving..." : "Save"} {saving ? "Saving..." : "Save"}
@@ -141,7 +141,7 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
</span> </span>
<button <button
className="text-xs" className="text-xs"
style={{ color: "#ef4444" }} style={{ color: "var(--forge-danger)" }}
> >
Remove Remove
</button> </button>
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; 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({ const term = new XTerm({
theme: { theme: {
background: "#121212", background: bg,
foreground: "#f2f2f2", foreground: fg,
cursor: "#946200", cursor: accent,
selectionBackground: "#946200", selectionBackground: accent,
selectionForeground: "#f2f2f2", selectionForeground: fg,
black: "#121212", black: bg,
brightBlack: "#666666", brightBlack: secondary,
white: "#f2f2f2", white: fg,
brightWhite: "#ffffff", brightWhite: fg,
yellow: "#946200", yellow: accent,
brightYellow: "#c48800", brightYellow: accentHover,
}, },
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "var(--font-mono)",
fontSize: 13, fontSize: 13,
cursorBlink: true, cursorBlink: true,
}); });
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full" 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 { Terminal } from "../components/terminal/Terminal";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import { useTheme } from "../hooks/useTheme"; 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 = [ const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
{ path: "/editor", label: "Editor", icon: "\u270E" }, { path: "/editor", label: "Editor", Icon: Code2 },
{ path: "/toolset", label: "Toolset", icon: "\u2699" }, { path: "/toolset", label: "Toolset", Icon: Wrench },
{ path: "/build", label: "Build", icon: "\u2692" }, { path: "/build", label: "Build", Icon: Hammer },
{ path: "/server", label: "Server", icon: "\u25B6" }, { path: "/server", label: "Server", Icon: Play },
{ path: "/repos", label: "Repos", icon: "\u2387" }, { path: "/repos", label: "Repos", Icon: GitBranch },
{ path: "/settings", label: "Settings", icon: "\u2318" }, { path: "/settings", label: "Settings", Icon: Settings },
]; ];
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { subscribe } = useWebSocket(); const { subscribe } = useWebSocket();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
useEffect(() => { useEffect(() => {
return subscribe("git:upstream-update", (event) => { return subscribe("git:upstream-update", (event) => {
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
}; };
return ( 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 */} {/* Left sidebar nav */}
<nav <nav
className="flex shrink-0 flex-col" aria-label="Main navigation"
style={{ style={{
display: "flex",
flexDirection: "column",
width: "56px", width: "56px",
flexShrink: 0,
borderRight: "1px solid var(--forge-border)", borderRight: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
}} }}
> >
<Link <Link
to="/" to="/"
className="flex items-center justify-center py-3" style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
title="Dashboard" title="Dashboard"
> >
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} /> <img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
</Link> </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) => { {NAV_ITEMS.map((item) => {
const isActive = const isActive =
item.path === "/" item.path === "/"
@@ -115,20 +133,26 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<Link <Link
key={item.path} key={item.path}
to={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={{ style={{
borderLeft: isActive display: "flex",
? "3px solid var(--forge-accent)" flexDirection: "column",
: "3px solid transparent", alignItems: "center",
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined, 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)", 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} title={item.label}
> >
<span className="text-base">{item.icon}</span> <item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span> <span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
{badge > 0 && ( {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} {badge}
</span> </span>
)} )}
@@ -139,69 +163,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5" style={{
style={{ color: "var(--forge-text-secondary)" }} 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`} 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> </button>
</nav> </nav>
{/* Main content area */} {/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden"> <div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
<header <header
className="flex shrink-0 items-center gap-4 px-4 py-1.5" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} 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
<span style={{
className="text-lg font-bold" fontFamily: "var(--font-heading)",
style={{ fontSize: "var(--text-lg)",
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", fontWeight: 700,
color: "var(--forge-accent)", color: "var(--forge-accent)",
}} }}
> >
Layonara Forge Layonara Forge
</span> </span>
</div>
<div className="flex-1" />
</header> </header>
<div className="flex flex-1 overflow-hidden"> <div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{sidebar && ( {sidebar && showSidebar && (
<aside <aside
className="shrink-0 overflow-hidden"
style={{ style={{
width: "250px", width: "250px",
flexShrink: 0,
overflow: "hidden",
borderRight: "1px solid var(--forge-border)", borderRight: "1px solid var(--forge-border)",
}} }}
> >
{sidebar} {sidebar}
</aside> </aside>
)} )}
<main className="flex-1 overflow-hidden"> <main style={{ flex: 1, overflow: "hidden" }}>
<Outlet /> <Outlet />
</main> </main>
</div> </div>
<button <button
onClick={() => setTerminalOpen((v) => !v)} 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={{ style={{
borderTop: "1px solid var(--forge-border)", display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.75rem",
color: "var(--forge-text-secondary)", 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> <span>Terminal</span>
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button> </button>
{terminalOpen && ( {terminalOpen && (
<div <div
className="shrink-0 overflow-hidden"
style={{ style={{
height: "300px", height: "300px",
flexShrink: 0,
overflow: "hidden",
borderTop: "1px solid var(--forge-border)", borderTop: "1px solid var(--forge-border)",
}} }}
> >
+25 -12
View File
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
export function SetupLayout() { export function SetupLayout() {
return ( return (
<div <div
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
style={{ style={{
minHeight: "100vh",
backgroundColor: "var(--forge-bg)", 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"> <div style={{ width: "100%", maxWidth: "52rem", marginTop: "4vh" }}>
<h1 <div style={{ marginBottom: "2rem" }}>
className="mb-8 text-center text-3xl font-bold" <h1
style={{ style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", fontFamily: "var(--font-heading)",
color: "var(--forge-accent)", fontSize: "var(--text-2xl)",
}} fontWeight: 700,
> color: "var(--forge-accent)",
Layonara Forge margin: 0,
</h1> }}
>
Layonara Forge
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Development environment setup
</p>
</div>
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+214 -70
View File
@@ -1,6 +1,17 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api"; import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import {
Hammer,
Package,
Cpu,
Play,
Archive,
Upload,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
type BuildStatus = "idle" | "building" | "success" | "failed"; type BuildStatus = "idle" | "building" | "success" | "failed";
@@ -11,15 +22,38 @@ interface BuildSectionState {
} }
function StatusBadge({ status }: { status: BuildStatus }) { function StatusBadge({ status }: { status: BuildStatus }) {
const colors: Record<BuildStatus, string> = { const styles: Record<BuildStatus, React.CSSProperties> = {
idle: "bg-gray-500/20 text-gray-400", idle: {
building: "bg-yellow-500/20 text-yellow-400", backgroundColor: "var(--forge-surface-raised)",
success: "bg-green-500/20 text-green-400", color: "var(--forge-text-secondary)",
failed: "bg-red-500/20 text-red-400", },
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 ( 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} {status}
</span> </span>
); );
@@ -43,31 +77,47 @@ function BuildOutput({
}, [lines, collapsed]); }, [lines, collapsed]);
return ( return (
<div className="mt-2"> <div style={{ marginTop: "0.75rem" }}>
<button <button
onClick={onToggle} onClick={onToggle}
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80" style={{
style={{ color: "var(--forge-text-secondary)" }} 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> <span>Output ({lines.length} lines)</span>
</button> </button>
{!collapsed && ( {!collapsed && (
<div <div
ref={scrollRef} ref={scrollRef}
className="mt-1 max-h-64 overflow-auto rounded p-3"
style={{ style={{
backgroundColor: "#0d1117", marginTop: "0.5rem",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", maxHeight: "16rem",
fontSize: "12px", overflowY: "auto",
lineHeight: "1.5", 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 ? ( {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) => ( lines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}> <div key={i} style={{ color: "var(--forge-log-text)" }}>
{line} {line}
</div> </div>
)) ))
@@ -83,27 +133,29 @@ function ActionButton({
onClick, onClick,
disabled, disabled,
variant = "default", variant = "default",
icon,
}: { }: {
label: string; label: string;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
variant?: "default" | "primary" | "warning"; variant?: "default" | "primary" | "warning";
icon?: React.ReactNode;
}) { }) {
const styles = { const variantStyles: Record<string, React.CSSProperties> = {
default: { default: {
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface-raised)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
color: "var(--forge-text)", color: "var(--forge-text)",
}, },
primary: { primary: {
backgroundColor: "var(--forge-accent)", backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)", border: "none",
color: "#fff", color: "var(--forge-accent-text)",
}, },
warning: { warning: {
backgroundColor: "#854d0e", backgroundColor: "var(--forge-warning-bg)",
borderColor: "#a16207", border: "1px solid var(--forge-warning-border)",
color: "#fef08a", color: "var(--forge-warning)",
}, },
}; };
@@ -111,9 +163,22 @@ function ActionButton({
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50" style={{
style={styles[variant]} ...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} {label}
</button> </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 ( return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}> <div
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}> style={{
Build Pipeline height: "100%",
</h2> 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 */} {/* Module Section */}
<section <section style={cardStyle}>
className="mb-6 rounded-lg border p-4" <div style={sectionHeaderStyle}>
style={{ <div style={sectionTitleStyle}>
backgroundColor: "var(--forge-surface)", <Hammer size={14} />
borderColor: "var(--forge-border)", <span>Module</span>
}} </div>
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Module</h3>
<StatusBadge status={module.status} /> <StatusBadge status={module.status} />
</div> </div>
<div className="flex flex-wrap gap-2"> <div style={buttonRowStyle}>
<ActionButton <ActionButton
label="Compile" label="Compile"
variant="primary" variant="primary"
icon={<Play size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")} onClick={() => handleAction(() => api.build.compileModule(), "module")}
/> />
<ActionButton <ActionButton
label="Pack Module" label="Pack Module"
icon={<Archive size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")} onClick={() => handleAction(() => api.build.packModule(), "module")}
/> />
<ActionButton <ActionButton
label="Deploy to Server" label="Deploy to Server"
variant="warning" variant="warning"
icon={<Upload size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.deploy(), "module")} onClick={() => handleAction(() => api.build.deploy(), "module")}
/> />
@@ -238,21 +365,19 @@ export function Build() {
</section> </section>
{/* Haks Section */} {/* Haks Section */}
<section <section style={cardStyle}>
className="mb-6 rounded-lg border p-4" <div style={sectionHeaderStyle}>
style={{ <div style={sectionTitleStyle}>
backgroundColor: "var(--forge-surface)", <Package size={14} />
borderColor: "var(--forge-border)", <span>Haks</span>
}} </div>
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Haks</h3>
<StatusBadge status={haks.status} /> <StatusBadge status={haks.status} />
</div> </div>
<div className="flex gap-2"> <div style={buttonRowStyle}>
<ActionButton <ActionButton
label="Build Haks" label="Build Haks"
variant="primary" variant="primary"
icon={<Play size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildHaks(), "haks")} onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
/> />
@@ -265,38 +390,49 @@ export function Build() {
</section> </section>
{/* NWNX Section */} {/* NWNX Section */}
<section <section style={cardStyle}>
className="rounded-lg border p-4" <div style={sectionHeaderStyle}>
style={{ <div style={sectionTitleStyle}>
backgroundColor: "var(--forge-surface)", <Cpu size={14} />
borderColor: "var(--forge-border)", <span>NWNX</span>
}} <span
> style={{
<div className="mb-3 flex items-center justify-between"> fontWeight: 400,
<h3 className="text-lg font-semibold"> textTransform: "none",
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span> opacity: 0.6,
</h3> letterSpacing: "normal",
}}
>
(Advanced)
</span>
</div>
<StatusBadge status={nwnx.status} /> <StatusBadge status={nwnx.status} />
</div> </div>
<div className="mb-3 flex flex-wrap gap-2"> <div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
<ActionButton <ActionButton
label="Build All" label="Build All"
variant="primary" variant="primary"
icon={<Play size={14} />}
disabled={isBuilding} disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")} onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/> />
</div> </div>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input <input
type="text" type="text"
value={nwnxTarget} value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)} onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)" placeholder="Target (e.g. Item, Creature)"
className="rounded border px-3 py-1.5 text-sm"
style={{ style={{
backgroundColor: "var(--forge-bg)", 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)", color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
outline: "none",
flex: "0 1 16rem",
}} }}
/> />
<ActionButton <ActionButton
@@ -308,10 +444,18 @@ export function Build() {
/> />
</div> </div>
<p <p
className="mt-2 text-xs" style={{
style={{ color: "#f59e0b" }} 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> </p>
<BuildOutput <BuildOutput
lines={nwnx.output} lines={nwnx.output}
+119 -103
View File
@@ -1,21 +1,68 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../services/api"; 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 }) { function StatusBadge({ status }: { status: string }) {
const color = const color =
status === "running" status === "running"
? "#4ade80" ? "var(--forge-success)"
: status === "stopped" : status === "stopped" || status === "exited" || status === "not created"
? "#f87171" ? "var(--forge-danger)"
: "#fbbf24"; : "var(--forge-warning)";
return ( return (
<span <span
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold" style={{
style={{ backgroundColor: `${color}20`, color }} 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} {status}
</span> </span>
); );
@@ -57,49 +104,36 @@ function ServerCard() {
} }
}; };
const isRunning = status.nwserver === "running";
return ( return (
<div <div style={card}>
className="rounded-lg p-6" <h3 style={cardTitle}><Server size={14} /> Server</h3>
style={{ <div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
backgroundColor: "var(--forge-surface)", <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
border: "1px solid var(--forge-border)", <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
}} <StatusBadge status={status.nwserver} />
> </div>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
Server Status <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
</h3> <StatusBadge status={status.mariadb} />
<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> </div>
</div> </div>
<div className="mt-4"> <div style={{ marginTop: "1rem" }}>
<button <button
onClick={toggle} onClick={toggle}
disabled={loading} disabled={loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ style={{
backgroundColor: ...primaryBtn,
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)", backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
color: status.nwserver === "running" ? "#fca5a5" : "#000", 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 {loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
? "..."
: status.nwserver === "running"
? "Stop Server"
: "Start Server"}
</button> </button>
</div> </div>
</div> </div>
@@ -120,39 +154,32 @@ function ReposSummary() {
}, []); }, []);
return ( return (
<div <div style={card}>
className="rounded-lg p-6" <h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
style={{ <div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
backgroundColor: "var(--forge-surface)", {repos.map((repo, i) => {
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) => {
const s = repoStatus[repo]; const s = repoStatus[repo];
const branch = (s?.branch as string) || "\u2014"; const branch = (s?.branch as string) || "\u2014";
const clean = s?.clean !== false; const clean = s?.clean !== false;
return ( return (
<div <div
key={repo} key={repo}
className="flex items-center justify-between rounded p-3" style={{
style={{ backgroundColor: "var(--forge-bg)" }} 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} {repo}
</span> </span>
<div className="flex items-center gap-2"> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{branch} {branch}
</span> </span>
<span <span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
title={clean ? "Clean" : "Uncommitted changes"}
/>
</div> </div>
</div> </div>
); );
@@ -166,41 +193,38 @@ function QuickActions() {
const navigate = useNavigate(); const navigate = useNavigate();
const actions = [ const actions = [
{ label: "Build Module", onClick: () => navigate("/build") }, { label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
{ label: "Build Haks", onClick: () => navigate("/build") }, { label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
{ label: "Open Editor", onClick: () => navigate("/editor") }, { label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
{
label: "Open Terminal",
onClick: () => {
/* terminal is toggled from IDELayout via Ctrl+` */
navigate("/editor");
},
},
]; ];
return ( return (
<div <div style={card}>
className="rounded-lg p-6" <h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
style={{ <div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
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">
{actions.map((a) => ( {actions.map((a) => (
<button <button
key={a.label} key={a.label}
onClick={a.onClick} onClick={a.onClick}
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
style={{ style={{
backgroundColor: "var(--forge-bg)", display: "flex",
color: "var(--forge-text)", alignItems: "center",
gap: "0.625rem",
padding: "0.5rem 0.75rem",
borderRadius: "0.375rem",
border: "1px solid var(--forge-border)", 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} {a.label}
</button> </button>
))} ))}
@@ -211,24 +235,16 @@ function QuickActions() {
export function Dashboard() { export function Dashboard() {
return ( return (
<div className="h-full overflow-y-auto p-6"> <div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div className="mb-8 text-center"> <div style={{ maxWidth: "56rem", margin: "0 auto" }}>
<h1 <h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
className="text-3xl font-bold" Dashboard
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</h1> </h1>
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
NWN Development Environment Server, repositories, and quick actions
</p> </p>
</div> <div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
<div className="mx-auto max-w-3xl space-y-4"> <ServerCard />
<ServerCard />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<ReposSummary /> <ReposSummary />
<QuickActions /> <QuickActions />
</div> </div>
+57 -11
View File
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
import { CreatureEditor } from "../components/gff/CreatureEditor"; import { CreatureEditor } from "../components/gff/CreatureEditor";
import { AreaEditor } from "../components/gff/AreaEditor"; import { AreaEditor } from "../components/gff/AreaEditor";
import { DialogEditor } from "../components/gff/DialogEditor"; 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"]; 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, markClean,
} = editorState; } = editorState;
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({}); const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
const tabs = useMemo( const tabs = useMemo(
@@ -105,8 +105,28 @@ export function Editor({ editorState }: EditorProps) {
const renderEditor = () => { const renderEditor = () => {
if (!activeTab) { if (!activeTab) {
return ( return (
<div className="flex h-full items-center justify-center"> <div
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg"> 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 Open a file from the File Explorer to start editing
</p> </p>
</div> </div>
@@ -139,22 +159,41 @@ export function Editor({ editorState }: EditorProps) {
} }
return ( return (
<div className="flex h-full flex-col"> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{isActiveGff && activeMode === "raw" && ( {isActiveGff && activeMode === "raw" && (
<div <div
className="flex shrink-0 items-center justify-end border-b px-4 py-1" style={{
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }} display: "flex",
alignItems: "center",
justifyContent: "flex-end",
flexShrink: 0,
padding: "4px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
> >
<button <button
onClick={handleSwitchToVisual} onClick={handleSwitchToVisual}
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80" style={{
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }} 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 Switch to Visual Editor
</button> </button>
</div> </div>
)} )}
<div className="flex-1 overflow-hidden"> <div style={{ flex: 1, overflow: "hidden" }}>
<MonacoEditor <MonacoEditor
key={activeTab} key={activeTab}
filePath={activeFilePath} filePath={activeFilePath}
@@ -167,14 +206,21 @@ export function Editor({ editorState }: EditorProps) {
}; };
return ( 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 <EditorTabs
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
onSelect={selectTab} onSelect={selectTab}
onClose={closeFile} onClose={closeFile}
/> />
<div className="flex-1 overflow-hidden"> <div style={{ flex: 1, overflow: "hidden" }}>
{renderEditor()} {renderEditor()}
</div> </div>
</div> </div>
+473 -89
View File
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "../services/api"; import { api } from "../services/api";
import { CommitDialog } from "../components/CommitDialog"; import { CommitDialog } from "../components/CommitDialog";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import {
GitBranch,
GitCommit,
GitPullRequest,
Download,
Upload,
Copy,
FileCode,
AlertCircle,
CheckCircle,
X,
} from "lucide-react";
interface RepoStatus { interface RepoStatus {
modified: string[]; modified: string[];
@@ -26,6 +38,54 @@ interface PrForm {
body: string; 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() { export function Repos() {
const [repos, setRepos] = useState<RepoInfo[]>([]); const [repos, setRepos] = useState<RepoInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -133,106 +193,247 @@ export function Repos() {
const isDirty = (status?: RepoStatus) => const isDirty = (status?: RepoStatus) =>
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0); 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) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}> <div style={{
Loading repositories... display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-sans)",
fontSize: "var(--text-base)",
}}>
Loading repositories
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}> <div style={{
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}> height: "100%",
Repositories overflowY: "auto",
</h2> 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 && ( {error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400"> <div style={{
{error} display: "flex",
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button> 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> </div>
)} )}
{/* PR success banner */}
{prResult && ( {prResult && (
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400"> <div style={{
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a> display: "flex",
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button> 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>
)} )}
<div className="space-y-4"> {/* Repo cards */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{repos.map((repo) => ( {repos.map((repo) => (
<section <section
key={repo.name} key={repo.name}
className="rounded-lg border p-4" style={{
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }} 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"> {/* Card header */}
<div className="flex items-center gap-3"> <div style={{
<h3 className="text-lg font-semibold">{repo.name}</h3> display: "flex",
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}> 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} {repo.branch}
</span> </span>
{repo.cloned && repo.status && ( {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) ? (
{isDirty(repo.status) ? "dirty" : "clean"} <span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
</span> dirty
{repo.status.behind > 0 && ( </span>
<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 <span style={badge("var(--forge-success-bg)", "var(--forge-success)")}>
<CheckCircle size={11} />
clean
</span> </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 && ( {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 {repo.status.ahead} ahead
</span> </span>
)} )}
</> </>
)} )}
</div> </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} {repo.upstream}
</span> </span>
</div> </div>
{/* Actions */}
{!repo.cloned ? ( {!repo.cloned ? (
<button <button
onClick={() => handleClone(repo.name)} onClick={() => handleClone(repo.name)}
disabled={actionLoading[repo.name]} disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
> >
{actionLoading[repo.name] ? "Cloning..." : "Clone"} <Copy size={14} />
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
</button> </button>
) : ( ) : (
<> <>
<div className="mb-3 flex flex-wrap gap-2"> <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
<button <button
onClick={() => handlePull(repo.name)} onClick={() => handlePull(repo.name)}
disabled={actionLoading[repo.name]} disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
> >
<Download size={14} />
Pull Pull
</button> </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 <button
onClick={() => setCommitRepo(repo.name)} onClick={() => setCommitRepo(repo.name)}
disabled={!isDirty(repo.status)} disabled={!isDirty(repo.status)}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }} ...accentBtn,
...disabledStyle(!isDirty(repo.status)),
}}
> >
<GitCommit size={14} />
Commit Commit
</button> </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 <button
onClick={() => onClick={() =>
setPrForm({ setPrForm({
@@ -241,39 +442,104 @@ export function Repos() {
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`, 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={outlineBtn}
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
> >
<GitPullRequest size={14} />
Create PR Create PR
</button> </button>
</div> </div>
{/* Changed files list */}
{repo.status && isDirty(repo.status) && ( {repo.status && isDirty(repo.status) && (
<div className="mt-2"> <div style={{
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}> border: "1px solid var(--forge-border)",
Changes 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>
<div className="space-y-0.5">
<div>
{repo.status.modified.map((f) => ( {repo.status.modified.map((f) => (
<div <div
key={f} 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)} 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={{
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span> fontWeight: 700,
color: "var(--forge-warning)",
width: "1rem",
textAlign: "center",
}}>M</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div> </div>
))} ))}
{repo.status.staged.map((f) => ( {repo.status.staged.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs"> <div
<span className="font-medium text-green-400">S</span> key={f}
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span> 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> </div>
))} ))}
{repo.status.untracked.map((f) => ( {repo.status.untracked.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs"> <div
<span className="font-medium text-gray-400">?</span> key={f}
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span> 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>
))} ))}
</div> </div>
@@ -285,6 +551,7 @@ export function Repos() {
))} ))}
</div> </div>
{/* Commit dialog */}
{commitRepo && ( {commitRepo && (
<CommitDialog <CommitDialog
repo={commitRepo} repo={commitRepo}
@@ -296,71 +563,188 @@ export function Repos() {
/> />
)} )}
{/* PR form modal */}
{prForm && ( {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 <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()} 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)" }}> <div style={{
Create Pull Request {prForm.repo} display: "flex",
</h3> 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 <input
type="text" type="text"
value={prForm.title} value={prForm.title}
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })} onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
placeholder="PR Title" placeholder="PR Title"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} 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 <textarea
value={prForm.body} value={prForm.body}
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })} onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
rows={8} rows={8}
className="mb-4 w-full rounded border px-3 py-1.5 text-sm" style={{
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }} 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 <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
onClick={() => setPrForm(null)} <button onClick={() => setPrForm(null)} style={outlineBtn}>
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Cancel Cancel
</button> </button>
<button <button
onClick={handleCreatePr} onClick={handleCreatePr}
disabled={!prForm.title.trim() || actionLoading.pr} disabled={!prForm.title.trim() || actionLoading.pr}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" style={{
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }} ...accentBtn,
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
}}
> >
{actionLoading.pr ? "Creating..." : "Submit PR"} <GitPullRequest size={14} />
{actionLoading.pr ? "Creating…" : "Submit PR"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Diff modal */}
{diffView && ( {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 <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()} 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"> <div style={{
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}> display: "flex",
Diff {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""} alignItems: "center",
</h3> justifyContent: "space-between",
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}> 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 Close
</button> </button>
</div> </div>
<pre
className="whitespace-pre-wrap text-xs" <pre style={{
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }} 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"} {diffView.diff || "No changes"}
</pre> </pre>
</div> </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 { Editor as ReactMonacoEditor } from "@monaco-editor/react";
import { api } from "../services/api"; import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket"; 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; type ServerState = "running" | "exited" | "not created" | string;
function StatusBadge({ label, state }: { label: string; state: ServerState }) { function StatusBadge({ label, state }: { label: string; state: ServerState }) {
const color = const dotColor =
state === "running" state === "running"
? "bg-green-500/20 text-green-400" ? "var(--forge-success)"
: state === "exited" : state === "exited"
? "bg-red-500/20 text-red-400" ? "var(--forge-danger)"
: "bg-gray-500/20 text-gray-400"; : "var(--forge-warning)";
return ( return (
<div className="flex items-center gap-2"> <div
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}> style={{
{label}: 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>
<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} {state}
</span> </span>
</div> </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() { function ControlsPanel() {
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({ const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
nwserver: "unknown", nwserver: "unknown",
@@ -63,49 +185,75 @@ function ControlsPanel() {
return ( return (
<section <section
className="rounded-lg border p-4"
style={{ style={{
backgroundColor: "var(--forge-surface)", 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)" }}> <SectionHeader icon={<ServerIcon size={16} />} label="Server Controls" />
Server Controls
</h3> <div
<div className="mb-4 flex gap-4"> style={{
display: "flex",
flexWrap: "wrap",
gap: "1.25rem",
marginBottom: "1.25rem",
}}
>
<StatusBadge label="NWN Server" state={status.nwserver} /> <StatusBadge label="NWN Server" state={status.nwserver} />
<StatusBadge label="MariaDB" state={status.mariadb} /> <StatusBadge label="MariaDB" state={status.mariadb} />
</div> </div>
<div className="flex flex-wrap gap-2">
{(["start", "stop", "restart", "config"] as const).map((action) => ( <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<button <HoverButton
key={action} onClick={() => handleAction("start")}
onClick={() => handleAction(action)} disabled={loading !== null}
disabled={loading !== null} bg="var(--forge-accent)"
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50" bgHover="var(--forge-accent-hover)"
style={{ border="var(--forge-accent)"
backgroundColor: color="var(--forge-accent-text)"
action === "start" >
? "var(--forge-accent)" <Play size={14} />
: action === "stop" {loading === "start" ? "Starting..." : "Start"}
? "#991b1b" </HoverButton>
: "var(--forge-surface)",
borderColor: <HoverButton
action === "start" onClick={() => handleAction("stop")}
? "var(--forge-accent)" disabled={loading !== null}
: action === "stop" bg="var(--forge-danger-bg)"
? "#dc2626" bgHover="var(--forge-danger-border)"
: "var(--forge-border)", border="var(--forge-danger-border)"
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)", color="var(--forge-danger)"
}} >
> <Square size={14} />
{loading === action {loading === "stop" ? "Stopping..." : "Stop"}
? "..." </HoverButton>
: action === "config"
? "Generate Config" <HoverButton
: action.charAt(0).toUpperCase() + action.slice(1)} onClick={() => handleAction("restart")}
</button> 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> </div>
</section> </section>
); );
@@ -141,65 +289,119 @@ function LogViewer() {
return ( return (
<section <section
className="flex flex-col rounded-lg border"
style={{ style={{
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
height: "350px", borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}} }}
> >
<div <div
className="flex shrink-0 items-center gap-2 px-4 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} 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" }}>
Server Logs <ScrollText size={16} />
</h3> </span>
<div className="flex-1" /> <span
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
className="rounded border px-2 py-1 text-xs"
style={{ style={{
backgroundColor: "var(--forge-bg)", fontSize: "var(--text-xs)",
borderColor: "var(--forge-border)", fontWeight: 700,
color: "var(--forge-text)", fontFamily: "var(--font-heading)",
width: "200px", textTransform: "uppercase",
}} letterSpacing: "0.08em",
/>
<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)",
color: "var(--forge-text-secondary)", 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 Clear
</button> </HoverButton>
</div> </div>
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 overflow-auto p-3"
style={{ style={{
backgroundColor: "#0d1117", backgroundColor: "var(--forge-log-bg)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", color: "var(--forge-log-text)",
fontSize: "12px", fontFamily: "var(--font-mono)",
lineHeight: "1.5", fontSize: 12,
lineHeight: 1.6,
padding: "0.75rem 1rem",
overflowY: "auto",
height: 350,
borderRadius: "0 0 0.75rem 0.75rem",
}} }}
> >
{filteredLines.length === 0 ? ( {filteredLines.length === 0 ? (
@@ -208,7 +410,7 @@ function LogViewer() {
</span> </span>
) : ( ) : (
filteredLines.map((line, i) => ( filteredLines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}> <div key={i} style={{ color: "var(--forge-log-text)" }}>
{line} {line}
</div> </div>
)) ))
@@ -245,31 +447,57 @@ function SQLConsole() {
return ( return (
<section <section
className="flex flex-col rounded-lg border"
style={{ style={{
backgroundColor: "var(--forge-surface)", backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)", border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}} }}
> >
<div <div
className="flex shrink-0 items-center gap-2 px-4 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} 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 SQL Console
</h3> </span>
<div className="flex-1" />
<div style={{ flex: 1 }} />
{history.length > 0 && ( {history.length > 0 && (
<select <select
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="rounded border px-2 py-1 text-xs" value=""
style={{ style={{
backgroundColor: "var(--forge-bg)", 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)", color: "var(--forge-text-secondary)",
maxWidth: "200px", maxWidth: 200,
outline: "none",
}} }}
value=""
> >
<option value="" disabled> <option value="" disabled>
History ({history.length}) History ({history.length})
@@ -281,21 +509,21 @@ function SQLConsole() {
))} ))}
</select> </select>
)} )}
<button
<HoverButton
onClick={execute} onClick={execute}
disabled={loading || !query.trim()} disabled={loading || !query.trim()}
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50" bg="var(--forge-accent)"
style={{ bgHover="var(--forge-accent-hover)"
backgroundColor: "var(--forge-accent)", border="var(--forge-accent)"
borderColor: "var(--forge-accent)", color="var(--forge-accent-text)"
color: "#fff",
}}
> >
<Play size={14} />
{loading ? "Running..." : "Execute"} {loading ? "Running..." : "Execute"}
</button> </HoverButton>
</div> </div>
<div style={{ height: "100px" }}> <div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
<ReactMonacoEditor <ReactMonacoEditor
value={query} value={query}
language="sql" language="sql"
@@ -319,71 +547,95 @@ function SQLConsole() {
{error && ( {error && (
<div <div
className="px-4 py-2 text-sm" style={{
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }} 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} {error}
</div> </div>
)} )}
{result && ( {result && (
<div <div style={{ overflowX: "auto" }}>
className="max-h-64 overflow-auto"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
{result.columns.length === 0 ? ( {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) Query executed successfully (no results)
</div> </div>
) : ( ) : (
<table className="w-full text-left text-xs"> <div style={{ maxHeight: 280, overflowY: "auto" }}>
<thead> <table
<tr> style={{
{result.columns.map((col) => ( width: "100%",
<th borderCollapse: "collapse",
key={col} fontSize: "var(--text-xs)",
className="sticky top-0 px-3 py-2 font-medium" }}
style={{ >
backgroundColor: "var(--forge-surface)", <thead>
borderBottom: "1px solid var(--forge-border)", <tr>
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)",
}}
>
{result.columns.map((col) => ( {result.columns.map((col) => (
<td <th
key={col} key={col}
className="px-3 py-1.5"
style={{ style={{
color: "var(--forge-text)", position: "sticky",
fontFamily: "'JetBrains Mono', monospace", 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]} {col}
</td> </th>
))} ))}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {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 <div
className="px-3 py-1 text-xs" style={{
style={{ color: "var(--forge-text-secondary)" }} 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" : ""} {result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
</div> </div>
@@ -395,11 +647,45 @@ function SQLConsole() {
export function Server() { export function Server() {
return ( return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}> <div
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}> style={{
Server Management height: "100%",
</h2> overflowY: "auto",
<div className="flex flex-col gap-6"> 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 /> <ControlsPanel />
<LogViewer /> <LogViewer />
<SQLConsole /> <SQLConsole />
+264 -135
View File
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../services/api"; import { api } from "../services/api";
import { useTheme } from "../hooks/useTheme"; 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({ function Section({
title, title,
icon,
children, children,
}: { }: {
title: string; title: string;
icon: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div <div style={sectionCard}>
className="rounded-lg p-5" <h3 style={sectionTitle}>{icon} {title}</h3>
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>
{children} {children}
</div> </div>
); );
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const currentPat = (config.githubPat as string) || ""; const hasPat = Boolean(config.githubPat && config.githubPat !== "***");
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set"; const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
}; };
return ( return (
<Section title="GitHub"> <Section title="Gitea Token" icon={<Key size={14} />}>
<div className="space-y-3"> <div>
<div> <p style={fieldLabel}>Personal Access Token</p>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <p style={fieldValue}>{masked}</p>
Personal Access Token </div>
</p> <div style={{ marginTop: "0.75rem" }}>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{masked}
</p>
</div>
{!editing ? ( {!editing ? (
<button <button onClick={() => setEditing(true)} style={primaryBtn}>
onClick={() => setEditing(true)} Update Token
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Update PAT
</button> </button>
) : ( ) : (
<div className="flex gap-2"> <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input <input
type="password" type="password"
value={pat} value={pat}
onChange={(e) => setPat(e.target.value)} onChange={(e) => setPat(e.target.value)}
placeholder="ghp_..." placeholder="Paste token"
className="flex-1 rounded px-3 py-1.5 text-sm" style={{ flex: 1 }}
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/> />
<button <button
onClick={save} onClick={save}
disabled={!pat || saving} disabled={!pat || saving}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{saving ? "..." : "Save"} {saving ? "Saving\u2026" : "Save"}
</button> </button>
<button <button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
onClick={() => {
setEditing(false);
setPat("");
}}
className="rounded px-3 py-1.5 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
Cancel Cancel
</button> </button>
</div> </div>
)} )}
{msg && (
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</div> </div>
{msg && (
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</Section> </Section>
); );
} }
@@ -118,16 +167,17 @@ function ThemeSection() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
return ( return (
<Section title="Theme"> <Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
<div className="flex items-center gap-4"> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="text-sm" style={{ color: "var(--forge-text)" }}> <div>
{theme === "dark" ? "Dark" : "Light"} Mode <p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
</span> {theme === "dark" ? "Dark" : "Light"} Mode
<button </p>
onClick={toggleTheme} <p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
className="rounded px-3 py-1.5 text-xs font-semibold" {theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} </p>
> </div>
<button onClick={toggleTheme} style={primaryBtn}>
Switch to {theme === "dark" ? "Light" : "Dark"} Switch to {theme === "dark" ? "Light" : "Dark"}
</button> </button>
</div> </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 ( return (
<Section title="Paths"> <div
<div className="space-y-3"> 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> <div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <p style={fieldLabel}>Workspace Path</p>
Workspace Path <PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.WORKSPACE_PATH as string) || "Not set"}
</p>
</div> </div>
<div> <div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> <p style={fieldLabel}>NWN Home Path</p>
NWN Home Path <PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</p> </div>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{(config.NWN_HOME_PATH as string) || "Not set"} <button
</p> 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>
</div> </div>
</Section> </Section>
@@ -172,40 +297,31 @@ function DockerSection() {
await api.docker.pull(image); await api.docker.pull(image);
setStatus((s) => ({ ...s, [image]: "Pulled" })); setStatus((s) => ({ ...s, [image]: "Pulled" }));
} catch (err) { } catch (err) {
setStatus((s) => ({ setStatus((s) => ({ ...s, [image]: err instanceof Error ? err.message : "Failed" }));
...s,
[image]: err instanceof Error ? err.message : "Failed",
}));
} finally { } finally {
setPulling((s) => ({ ...s, [image]: false })); setPulling((s) => ({ ...s, [image]: false }));
} }
}; };
return ( return (
<Section title="Docker Images"> <Section title="Docker Images" icon={<Container size={14} />}>
<div className="space-y-2"> <div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{images.map((image) => ( {images.map((image) => (
<div <div key={image} style={listRow}>
key={image} <span style={fieldValue}>{image}</span>
className="flex items-center justify-between rounded p-3" <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
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">
{status[image] && ( {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]} {status[image]}
</span> </span>
)} )}
<button <button
onClick={() => pull(image)} onClick={() => pull(image)}
disabled={pulling[image]} disabled={pulling[image]}
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{pulling[image] ? "..." : "Pull Latest"} <Download size={12} />
{pulling[image] ? "Pulling\u2026" : "Pull"}
</button> </button>
</div> </div>
</div> </div>
@@ -225,21 +341,20 @@ const SHORTCUTS = [
function ShortcutsSection() { function ShortcutsSection() {
return ( return (
<Section title="Keyboard Shortcuts"> <Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
<div className="space-y-1"> <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{SHORTCUTS.map((s) => ( {SHORTCUTS.map((s) => (
<div <div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
key={s.keys} <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
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)" }}>
{s.action} {s.action}
</span> </span>
<kbd <kbd
className="rounded px-2 py-0.5 font-mono text-xs"
style={{ 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)", border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
}} }}
@@ -255,11 +370,11 @@ function ShortcutsSection() {
function AboutSection() { function AboutSection() {
return ( return (
<Section title="About"> <Section title="About" icon={<Info size={14} />}>
<p className="text-sm" style={{ color: "var(--forge-text)" }}> <p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Layonara Forge v0.0.1 Layonara Forge v0.0.1
</p> </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 github.com/Layonara/layonara-forge
</p> </p>
</Section> </Section>
@@ -270,6 +385,7 @@ function ResetSection() {
const navigate = useNavigate(); const navigate = useNavigate();
const reset = async () => { const reset = async () => {
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
try { try {
await api.workspace.updateConfig({ setupComplete: false }); await api.workspace.updateConfig({ setupComplete: false });
} catch { } catch {
@@ -279,14 +395,28 @@ function ResetSection() {
}; };
return ( return (
<Section title="Reset"> <Section title="Reset" icon={<RotateCcw size={14} />}>
<button <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
onClick={reset} <p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
className="rounded px-4 py-2 text-sm font-semibold" Clear configuration and re-run the setup wizard
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }} </p>
> <button
Re-run Setup Wizard onClick={reset}
</button> 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> </Section>
); );
} }
@@ -299,24 +429,23 @@ export function Settings() {
}, []); }, []);
return ( return (
<div className="h-full overflow-y-auto p-6"> <div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<h2 <div style={{ maxWidth: "40rem" }}>
className="mb-6 text-xl font-bold" <h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
style={{ Settings
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", </h1>
color: "var(--forge-accent)", <p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
}} Configuration, theme, and environment
> </p>
Settings <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
</h2> <GitHubSection config={config} />
<div className="max-w-2xl space-y-4"> <ThemeSection />
<GitHubSection config={config} /> <PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
<ThemeSection /> <DockerSection />
<PathsSection config={config} /> <ShortcutsSection />
<DockerSection /> <AboutSection />
<ShortcutsSection /> <ResetSection />
<AboutSection /> </div>
<ResetSection />
</div> </div>
</div> </div>
); );
+452 -260
View File
@@ -5,9 +5,9 @@ import { api } from "../services/api";
const STEP_NAMES = [ const STEP_NAMES = [
"Welcome", "Welcome",
"Prerequisites", "Prerequisites",
"Gitea Token",
"Workspace", "Workspace",
"NWN Home", "NWN Home",
"Gitea Token",
"Fork Repos", "Fork Repos",
"Clone Repos", "Clone Repos",
"Pull Images", "Pull Images",
@@ -15,43 +15,144 @@ const STEP_NAMES = [
"Complete", "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 ( return (
<div className="mb-2"> <nav aria-label="Setup progress" style={{ paddingTop: "1.25rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--forge-border)", marginBottom: "1.5rem" }}>
<div className="flex items-center justify-center gap-1"> <ol style={{ display: "flex", alignItems: "center", width: "100%", listStyle: "none", margin: 0, padding: 0 }}>
{STEP_NAMES.map((name, i) => ( {PHASES.map((phase, i) => {
<div key={name} className="flex items-center gap-1"> const isComplete = i < currentPhase;
<div const isCurrent = i === currentPhase;
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold"
style={{ return (
backgroundColor: <li
i < current ? "var(--forge-accent)" : i === current ? "var(--forge-accent)" : "var(--forge-surface)", key={phase.label}
color: i <= current ? "#000" : "var(--forge-text-secondary)", style={{ display: "flex", alignItems: "center", flex: isLast(i) ? "none" : 1 }}
border: i === current ? "2px solid var(--forge-accent)" : "1px solid var(--forge-border)", aria-current={isCurrent ? "step" : undefined}
opacity: i < current ? 0.7 : 1,
}}
title={name}
> >
{i < current ? "\u2713" : i + 1} <div style={{ display: "flex", alignItems: "center", width: "100%" }}>
</div> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
{i < STEP_NAMES.length - 1 && ( <div
<div style={{
className="h-px w-3" width: "2rem",
style={{ height: "2rem",
backgroundColor: i < current ? "var(--forge-accent)" : "var(--forge-border)", borderRadius: "50%",
}} display: "flex",
/> alignItems: "center",
)} justifyContent: "center",
</div> fontSize: "0.8125rem",
))} fontWeight: 600,
</div> flexShrink: 0,
<p className="mt-3 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}> backgroundColor: isComplete
Step {current + 1} of {STEP_NAMES.length} &mdash; {STEP_NAMES[current]} ? "var(--forge-success)"
</p> : isCurrent
</div> ? "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({ function StepNav({
onNext, onNext,
onBack, onBack,
@@ -66,25 +167,46 @@ function StepNav({
nextDisabled?: boolean; nextDisabled?: boolean;
}) { }) {
return ( return (
<div className="mt-6 flex justify-between"> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "1.5rem", marginTop: "2rem", borderTop: "1px solid var(--forge-border)" }}>
{step > 0 ? ( <div>
<button {step > 0 && (
onClick={onBack} <button
className="rounded px-4 py-2 text-sm transition-colors hover:bg-white/10" onClick={onBack}
style={{ border: "1px solid var(--forge-border)", color: "var(--forge-text-secondary)" }} style={{
> color: "var(--forge-text-secondary)",
Back background: "none",
</button> border: "none",
) : ( fontSize: "var(--text-sm)",
<div /> 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 <button
onClick={onNext} onClick={onNext}
disabled={nextDisabled} disabled={nextDisabled}
className="rounded px-4 py-2 text-sm font-semibold transition-colors disabled:opacity-40" style={{
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} 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> </button>
</div> </div>
); );
@@ -92,10 +214,10 @@ function StepNav({
function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) { function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) {
return ( return (
<div className="mt-4 rounded p-3 text-sm" style={{ backgroundColor: "#3b1111", border: "1px solid #7f1d1d" }}> <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: "#fca5a5" }}>{error}</p> <p style={{ color: "var(--forge-danger)" }}>{error}</p>
{onRetry && ( {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 Retry
</button> </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 { interface StepProps {
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
@@ -110,24 +276,39 @@ interface StepProps {
function WelcomeStep({ onNext }: StepProps) { function WelcomeStep({ onNext }: StepProps) {
return ( return (
<div className="text-center"> <div style={{ padding: "2rem 0" }}>
<h2 <h2
className="text-2xl font-bold" style={{
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }} fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
> >
Welcome to Layonara Forge Welcome to Layonara Forge
</h2> </h2>
<p className="mt-4" style={{ color: "var(--forge-text-secondary)" }}> <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 This wizard will walk you through setting up your local NWN development environment &mdash; Docker,
access, workspace initialization, repository cloning, and database seeding. Gitea access, workspace initialization, repository cloning, and database seeding.
</p> </p>
<div className="mt-8"> <div style={{ marginTop: "2rem" }}>
<button <button
onClick={onNext} onClick={onNext}
className="rounded px-6 py-2.5 text-sm font-semibold" style={{
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} 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> </button>
</div> </div>
</div> </div>
@@ -156,21 +337,17 @@ function PrerequisitesStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Prerequisites</h2>
Prerequisites <p style={stepDesc}>Checking that Docker and the Forge backend are running.</p>
</h2> <div style={{ ...statusRow, marginTop: "1.5rem" }}>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <StatusDot status={status === "checking" || status === "idle" ? "working" : status === "ok" ? "ok" : "error"} />
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>
<div> <div>
<p style={{ color: "var(--forge-text)" }}>Docker &amp; Backend</p> <p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}> Docker &amp; Backend
</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{status === "checking" || status === "idle" {status === "checking" || status === "idle"
? "Checking..." ? "Checking\u2026"
: status === "ok" : status === "ok"
? "Backend is healthy" ? "Backend is healthy"
: "Backend unreachable"} : "Backend unreachable"}
@@ -205,67 +382,113 @@ function GiteaTokenStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Gitea Access Token</h2>
Gitea Access Token <p style={stepDesc}>
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
A Gitea token is needed to fork and push to Layonara repositories. Generate one at{" "} A Gitea token is needed to fork and push to Layonara repositories. Generate one at{" "}
<a <a href="https://gitea.layonara.com/user/settings/applications" target="_blank" rel="noreferrer" style={{ color: "var(--forge-accent)" }}>
href="https://gitea.layonara.com/user/settings/applications" gitea.layonara.com
target="_blank"
rel="noreferrer"
style={{ color: "var(--forge-accent)" }}
>
gitea.layonara.com/user/settings/applications
</a> </a>
</p> </p>
<div className="mt-4 flex gap-2"> <div style={{ marginTop: "1.5rem" }}>
<input <label style={fieldLabel}>Access Token</label>
type="password" <div style={{ display: "flex", gap: "0.5rem" }}>
value={token} <input
onChange={(e) => setToken(e.target.value)} type="password"
placeholder="Enter your Gitea token" value={token}
className="flex-1 rounded px-3 py-2 text-sm" onChange={(e) => setToken(e.target.value)}
style={{ placeholder="Paste your Gitea token"
backgroundColor: "var(--forge-bg)", style={{ flex: 1 }}
border: "1px solid var(--forge-border)", />
color: "var(--forge-text)", <button
}} onClick={validate}
/> disabled={!token || loading}
<button style={{ ...primaryBtn, opacity: !token || loading ? 0.4 : 1, cursor: !token || loading ? "not-allowed" : "pointer" }}
onClick={validate} >
disabled={!token || loading} {loading ? "Validating\u2026" : "Validate"}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40" </button>
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} </div>
>
{loading ? "..." : "Validate"}
</button>
</div> </div>
{username && ( {username && (
<p className="mt-3 text-sm" style={{ color: "#4ade80" }}> <div style={{ ...statusRow, marginTop: "1rem" }}>
{"\u2705"} Authenticated as <strong>{username}</strong> <StatusDot status="ok" />
</p> <span style={{ fontSize: "var(--text-sm)", color: "var(--forge-success)" }}>
Authenticated as <strong>{username}</strong>
</span>
</div>
)} )}
{error && <ErrorBox error={error} />} {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> </div>
); );
} }
function WorkspaceStep({ onNext, onBack }: StepProps) { function WorkspaceStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({}); const [config, setConfig] = useState<Record<string, unknown>>({});
const [wsPath, setWsPath] = useState("");
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { 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 () => { const init = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
if (wsPath) await api.workspace.updateConfig({ WORKSPACE_PATH: wsPath });
await api.workspace.init(); await api.workspace.init();
setInitialized(true); setInitialized(true);
} catch (err) { } catch (err) {
@@ -277,60 +500,58 @@ function WorkspaceStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Workspace</h2>
Workspace <p style={stepDesc}>Choose the directory where Forge stores repos, server data, and configuration.</p>
</h2> <div style={{ marginTop: "1.5rem" }}>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>Workspace Path</label>
Initialize the Forge workspace directory structure. <PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</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>
</div> </div>
<div className="mt-4"> <div style={{ marginTop: "1.25rem" }}>
<button <button
onClick={init} onClick={init}
disabled={loading || initialized} disabled={loading || initialized || !wsPath}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: loading || initialized || !wsPath ? 0.5 : 1, cursor: loading || initialized || !wsPath ? "not-allowed" : "pointer" }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{initialized ? "\u2705 Initialized" : loading ? "Initializing..." : "Initialize"} {initialized ? "\u2713 Initialized" : loading ? "Initializing\u2026" : "Initialize"}
</button> </button>
</div> </div>
{error && <ErrorBox error={error} onRetry={init} />} {error && <ErrorBox error={error} onRetry={init} />}
<StepNav onNext={onNext} onBack={onBack} step={3} /> <StepNav onNext={onNext} onBack={onBack} step={2} />
</div> </div>
); );
} }
function NwnHomeStep({ onNext, onBack }: StepProps) { function NwnHomeStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({}); const [config, setConfig] = useState<Record<string, unknown>>({});
const [nwnPath, setNwnPath] = useState("");
useEffect(() => { 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 ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>NWN Home Directory</h2>
NWN Home Directory <p style={stepDesc}>Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/).</p>
</h2> <div style={{ marginTop: "1.5rem" }}>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>NWN Home Path</label>
Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/). <PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</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>
</div> </div>
<StepNav onNext={onNext} onBack={onBack} step={4} /> <StepNav onNext={handleNext} onBack={onBack} step={3} />
</div> </div>
); );
} }
@@ -365,25 +586,17 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Fork Repositories</h2>
Fork Repositories <p style={stepDesc}>Fork the Layonara repositories to your Gitea account.</p>
</h2> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<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">
{forkableRepos.map((repo) => ( {forkableRepos.map((repo) => (
<div <div key={repo} style={{ ...statusRow, justifyContent: "space-between" }}>
key={repo}
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div> <div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}> <p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
layonara/{repo} layonara/{repo}
</p> </p>
{errors[repo] && ( {errors[repo] && (
<p className="mt-1 text-xs" style={{ color: "#fca5a5" }}> <p className="mt-1 text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]} {errors[repo]}
</p> </p>
)} )}
@@ -391,21 +604,17 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
<button <button
onClick={() => forkRepo(repo)} onClick={() => forkRepo(repo)}
disabled={forkStatus[repo] === "forked" || forkStatus[repo] === "forking"} disabled={forkStatus[repo] === "forked" || forkStatus[repo] === "forking"}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40" style={{ ...primaryBtn, fontSize: "var(--text-xs)", padding: "0.375rem 0.875rem", opacity: forkStatus[repo] === "forked" || forkStatus[repo] === "forking" ? 0.5 : 1 }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{forkStatus[repo] === "forked" {forkStatus[repo] === "forked"
? "\u2705 Forked" ? "\u2713 Forked"
: forkStatus[repo] === "forking" : forkStatus[repo] === "forking"
? "..." ? "Forking\u2026"
: "Fork"} : "Fork"}
</button> </button>
</div> </div>
))} ))}
<div <div style={{ ...statusRow, justifyContent: "space-between" }}>
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div> <div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}> <p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
plenarius/unified plenarius/unified
@@ -450,25 +659,19 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Clone Repositories</h2>
Clone Repositories <p style={stepDesc}>
</h2> Clone all repositories into the workspace. Gitea repos use your fork; unified clones from GitHub.
<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).
</p> </p>
<div className="mt-4 space-y-2"> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{repos.map((repo) => ( {repos.map((repo) => (
<div key={repo} className="flex items-center gap-3 rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}> <div key={repo} style={{ ...statusRow }}>
<span> <StatusDot status={
{cloneStatus[repo] === "cloned" cloneStatus[repo] === "cloned" ? "ok"
? "\u2705" : cloneStatus[repo] === "cloning" ? "working"
: cloneStatus[repo] === "cloning" : cloneStatus[repo] === "error" ? "error"
? "\u23F3" : "idle"
: cloneStatus[repo] === "error" } />
? "\u274C"
: "\u25CB"}
</span>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}> <span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{repo} {repo}
</span> </span>
@@ -478,21 +681,20 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
</span> </span>
)} )}
{errors[repo] && ( {errors[repo] && (
<span className="text-xs" style={{ color: "#fca5a5" }}> <span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]} {errors[repo]}
</span> </span>
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="mt-4"> <div style={{ marginTop: "1.25rem" }}>
<button <button
onClick={cloneAll} onClick={cloneAll}
disabled={cloning || allDone} disabled={cloning || allDone}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: cloning || allDone ? 0.5 : 1, cursor: cloning || allDone ? "not-allowed" : "pointer" }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{allDone ? "\u2705 All Cloned" : cloning ? "Cloning..." : "Clone All"} {allDone ? "\u2713 All Cloned" : cloning ? "Cloning\u2026" : "Clone All"}
</button> </button>
</div> </div>
<StepNav onNext={onNext} onBack={onBack} step={6} /> <StepNav onNext={onNext} onBack={onBack} step={6} />
@@ -526,43 +728,35 @@ function PullImagesStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Pull Docker Images</h2>
Pull Docker Images <p style={stepDesc}>Pull the required Docker images for the local dev environment.</p>
</h2> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<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">
{images.map((image) => ( {images.map((image) => (
<div key={image} className="flex items-center gap-3 rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}> <div key={image} style={{ ...statusRow }}>
<span> <StatusDot status={
{pullStatus[image] === "pulled" pullStatus[image] === "pulled" ? "ok"
? "\u2705" : pullStatus[image] === "pulling" ? "working"
: pullStatus[image] === "pulling" : pullStatus[image] === "error" ? "error"
? "\u23F3" : "idle"
: pullStatus[image] === "error" } />
? "\u274C"
: "\u25CB"}
</span>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}> <span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image} {image}
</span> </span>
{errors[image] && ( {errors[image] && (
<span className="text-xs" style={{ color: "#fca5a5" }}> <span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[image]} {errors[image]}
</span> </span>
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="mt-4"> <div style={{ marginTop: "1.25rem" }}>
<button <button
onClick={pullAll} onClick={pullAll}
disabled={pulling || allDone} disabled={pulling || allDone}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: pulling || allDone ? 0.5 : 1, cursor: pulling || allDone ? "not-allowed" : "pointer" }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{allDone ? "\u2705 All Pulled" : pulling ? "Pulling..." : "Pull Images"} {allDone ? "\u2713 All Pulled" : pulling ? "Pulling\u2026" : "Pull Images"}
</button> </button>
</div> </div>
<StepNav onNext={onNext} onBack={onBack} step={7} /> <StepNav onNext={onNext} onBack={onBack} step={7} />
@@ -592,56 +786,37 @@ function SeedDbStep({ onNext, onBack }: StepProps) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}> <h2 style={stepHeading}>Seed Database</h2>
Seed Database <p style={stepDesc}>Add your NWN CD key and player name so the dev server recognizes you as a DM.</p>
</h2> <div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<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">
<div> <div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>CD Key</label>
CD Key
</label>
<input <input
type="text" type="text"
value={cdKey} value={cdKey}
onChange={(e) => setCdKey(e.target.value.toUpperCase())} onChange={(e) => setCdKey(e.target.value.toUpperCase())}
placeholder="e.g. UPQNKG4R" placeholder="e.g. UPQNKG4R"
className="mt-1 w-full rounded px-3 py-2 text-sm" style={{ width: "100%" }}
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/> />
</div> </div>
<div> <div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}> <label style={fieldLabel}>Player Name</label>
Player Name
</label>
<input <input
type="text" type="text"
value={playerName} value={playerName}
onChange={(e) => setPlayerName(e.target.value)} onChange={(e) => setPlayerName(e.target.value)}
placeholder="e.g. contributor" placeholder="e.g. contributor"
className="mt-1 w-full rounded px-3 py-2 text-sm" style={{ width: "100%" }}
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/> />
</div> </div>
</div> </div>
<div className="mt-4"> <div style={{ marginTop: "1rem" }}>
<button <button
onClick={seed} onClick={seed}
disabled={!cdKey || !playerName || loading || seeded} disabled={!cdKey || !playerName || loading || seeded}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40" style={{ ...primaryBtn, opacity: !cdKey || !playerName || loading || seeded ? 0.5 : 1, cursor: !cdKey || !playerName || loading || seeded ? "not-allowed" : "pointer" }}
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
> >
{seeded ? "\u2705 Seeded" : loading ? "Seeding..." : "Seed"} {seeded ? "\u2713 Seeded" : loading ? "Seeding\u2026" : "Seed Database"}
</button> </button>
</div> </div>
{error && <ErrorBox error={error} onRetry={seed} />} {error && <ErrorBox error={error} onRetry={seed} />}
@@ -652,24 +827,39 @@ function SeedDbStep({ onNext, onBack }: StepProps) {
function CompleteStep({ onFinish }: { onFinish: () => void }) { function CompleteStep({ onFinish }: { onFinish: () => void }) {
return ( return (
<div className="text-center"> <div style={{ padding: "2rem 0" }}>
<h2 <h2
className="text-2xl font-bold" style={{
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }} fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
> >
Setup Complete! Setup Complete
</h2> </h2>
<p className="mt-4" style={{ color: "var(--forge-text-secondary)" }}> <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 Your Layonara Forge environment is ready. Build modules, edit scripts, manage repositories, and run the
dev server. local dev server.
</p> </p>
<div className="mt-8"> <div style={{ marginTop: "2rem" }}>
<button <button
onClick={onFinish} onClick={onFinish}
className="rounded px-6 py-2.5 text-sm font-semibold" style={{
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }} 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> </button>
</div> </div>
</div> </div>
@@ -695,9 +885,9 @@ export function Setup() {
const stepComponents = [ const stepComponents = [
<WelcomeStep key="welcome" onNext={next} onBack={back} />, <WelcomeStep key="welcome" onNext={next} onBack={back} />,
<PrerequisitesStep key="prereqs" 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} />, <WorkspaceStep key="workspace" onNext={next} onBack={back} />,
<NwnHomeStep key="nwnhome" 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} />, <ForkReposStep key="fork" onNext={next} onBack={back} />,
<CloneReposStep key="clone" onNext={next} onBack={back} />, <CloneReposStep key="clone" onNext={next} onBack={back} />,
<PullImagesStep key="pull" onNext={next} onBack={back} />, <PullImagesStep key="pull" onNext={next} onBack={back} />,
@@ -706,14 +896,16 @@ export function Setup() {
]; ];
return ( return (
<> <div
<StepIndicator current={step} /> style={{
<div backgroundColor: "var(--forge-surface)",
className="mt-4 rounded-lg p-8" border: "1px solid var(--forge-border)",
style={{ backgroundColor: "var(--forge-surface)", border: "1px solid var(--forge-border)" }} borderRadius: "0.75rem",
> padding: "0 2.5rem 2.5rem",
{stepComponents[step]} }}
</div> >
</> <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 { DiffEditor } from "@monaco-editor/react";
import { api } from "../services/api"; import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import {
Eye,
EyeOff,
FileCode,
Check,
X,
RefreshCw,
Trash2,
ArrowUpCircle,
} from "lucide-react";
interface ChangeEntry { interface ChangeEntry {
filename: string; filename: string;
@@ -16,61 +26,6 @@ interface DiffData {
filename: string; 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 { function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString(); return new Date(ts).toLocaleTimeString();
} }
@@ -222,217 +177,578 @@ export function Toolset() {
}; };
const handleDiscardAll = async () => { const handleDiscardAll = async () => {
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
await api.toolset.discardAll(); await api.toolset.discardAll();
refresh(); refresh();
}; };
return ( return (
<div <div
className="flex h-full flex-col overflow-hidden" style={{
style={{ color: "var(--forge-text)" }} display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
color: "var(--forge-text)",
}}
> >
{/* Status bar */} {/* Page heading */}
<div <div style={{ padding: "24px 28px 0" }}>
className="flex shrink-0 items-center justify-between px-6 py-3" <h1
style={{ borderBottom: "1px solid var(--forge-border)" }} style={{
> fontFamily: "var(--font-heading)",
<div className="flex items-center gap-4"> fontSize: "var(--text-xl)",
<h2 fontWeight: 700,
className="text-xl font-bold" color: "var(--forge-text)",
style={{ color: "var(--forge-accent)" }} 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 <div
</h2> style={{ display: "flex", alignItems: "center", gap: 8 }}
<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)" }}
> >
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> </span>
)} {lastChange && (
</div> <span
<div className="flex gap-2"> style={{
{active ? ( fontSize: "var(--text-xs)",
<ActionButton label="Stop Watcher" onClick={handleStop} /> color: "var(--forge-text-secondary)",
) : ( }}
<ActionButton >
label="Start Watcher" Last change: {formatTimestamp(lastChange)}
onClick={handleStart} </span>
variant="primary" )}
/> </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>
</div> </div>
{/* Action bar */} {/* Main content area */}
{changes.length > 0 && ( <div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: "0 28px 20px",
}}
>
{/* Changes card */}
<div <div
className="flex shrink-0 items-center gap-2 px-6 py-2" style={{
style={{ borderBottom: "1px solid var(--forge-border)" }} 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 {/* Card header with action bar */}
label="Apply Selected" <div
variant="primary" style={{
disabled={selected.size === 0} display: "flex",
onClick={handleApplySelected} alignItems: "center",
/> justifyContent: "space-between",
<ActionButton padding: "12px 16px",
label="Apply All" borderBottom: "1px solid var(--forge-border)",
variant="primary" backgroundColor: "var(--forge-surface-raised)",
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)" }}
> >
{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 <div
className="flex h-40 items-center justify-center text-sm" style={{ display: "flex", alignItems: "center", gap: 8 }}
style={{ color: "var(--forge-text-secondary)" }}
> >
{active <FileCode
? "Watching for changes in temp0/..." size={16}
: "Start the watcher to detect Toolset changes"} style={{ color: "var(--forge-accent)" }}
</div> />
) : ( <span
<table className="w-full text-sm"> style={{
<thead> fontSize: "var(--text-sm)",
<tr fontWeight: 600,
color: "var(--forge-text)",
}}
>
Pending Changes
</span>
{changes.length > 0 && (
<span
style={{ style={{
borderBottom: "1px solid var(--forge-border)", fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)", color: "var(--forge-text-secondary)",
marginLeft: 4,
}} }}
> >
<th className="px-6 py-2 text-left font-medium"> {selected.size} of {changes.length} selected
<input </span>
type="checkbox" )}
checked={selected.size === changes.length} </div>
onChange={toggleAll}
className="cursor-pointer" {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> <span style={{ fontSize: "var(--text-sm)" }}>
<th className="px-2 py-2 text-left font-medium">Filename</th> Watching for changes in temp0/...
<th className="px-2 py-2 text-left font-medium">Type</th> </span>
<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> <EyeOff
</tr> size={28}
</thead> style={{ marginBottom: 12, opacity: 0.4 }}
<tbody> />
{changes.map((change) => ( <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 <tr
key={change.filename}
className="cursor-pointer transition-colors hover:bg-white/5"
style={{ style={{
borderBottom: "1px solid var(--forge-border)", borderBottom: "1px solid var(--forge-border)",
backgroundColor: backgroundColor: "var(--forge-surface-raised)",
diffData?.filename === change.filename color: "var(--forge-text-secondary)",
? "var(--forge-surface)"
: undefined,
}} }}
onClick={() => viewDiff(change)}
> >
<td className="px-6 py-2"> <th
style={{
padding: "8px 16px",
textAlign: "left",
fontWeight: 500,
width: 40,
}}
>
<input <input
type="checkbox" type="checkbox"
checked={selected.has(change.filename)} checked={
onChange={(e) => { selected.size === changes.length &&
e.stopPropagation(); changes.length > 0
toggleSelect(change.filename); }
}} onChange={toggleAll}
onClick={(e) => e.stopPropagation()} style={{ cursor: "pointer" }}
className="cursor-pointer"
/> />
</td> </th>
<td className="px-2 py-2 font-mono">{change.filename}</td> <th
<td className="px-2 py-2"> style={{
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs"> padding: "8px 10px",
{change.gffType} textAlign: "left",
</span> fontWeight: 500,
</td> }}
<td
className="px-2 py-2 font-mono text-xs"
style={{ color: "var(--forge-text-secondary)" }}
> >
{change.repoPath ?? "—"} Filename
</td> </th>
<td <th
className="px-2 py-2 text-xs" style={{
style={{ color: "var(--forge-text-secondary)" }} padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
> >
{formatTimestamp(change.timestamp)} Type
</td> </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> </tr>
))} </thead>
</tbody> <tbody>
</table> {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> </div>
{/* Diff panel */} {/* Diff viewer panel */}
{diffData && ( {diffData && (
<div <div
ref={diffContainerRef} ref={diffContainerRef}
className="flex min-h-0 flex-1 flex-col" style={{
style={{ borderTop: "1px solid var(--forge-border)" }} 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 <div
className="flex shrink-0 items-center justify-between px-4 py-1.5"
style={{ 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)", borderBottom: "1px solid var(--forge-border)",
}} }}
> >
<span className="text-xs font-medium"> <div
Diff: {diffData.filename} 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 && ( {loading && (
<span style={{ color: "var(--forge-text-secondary)" }}> <span
{" "} style={{
(loading...) fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Loading...
</span> </span>
)} )}
</span> </div>
<button <button
onClick={() => setDiffData(null)} onClick={() => setDiffData(null)}
className="text-xs transition-opacity hover:opacity-80" style={{
style={{ color: "var(--forge-text-secondary)" }} 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 Close
</button> </button>
</div> </div>
<div className="min-h-0 flex-1">
{/* Diff content */}
<div
style={{
flex: 1,
minHeight: 0,
backgroundColor: "var(--forge-log-bg)",
}}
>
<DiffEditor <DiffEditor
original={diffData.original} original={diffData.original}
modified={diffData.modified} modified={diffData.modified}
+173 -13
View File
@@ -1,25 +1,185 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@fontsource-variable/manrope";
@import "@fontsource-variable/alegreya";
@import "@fontsource-variable/jetbrains-mono";
:root { :root {
--forge-bg: #121212; --forge-bg: oklch(15% 0.01 65);
--forge-surface: #1e1e2e; --forge-surface: oklch(20% 0.012 65);
--forge-border: #2e2e3e; --forge-surface-raised: oklch(24% 0.014 65);
--forge-accent: #946200; --forge-border: oklch(30% 0.014 65);
--forge-text: #f2f2f2; --forge-accent: oklch(58% 0.155 65);
--forge-text-secondary: #888888; --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 { :root.light {
--forge-bg: #f2f2f2; --forge-bg: oklch(95% 0.008 65);
--forge-surface: #ffffff; --forge-surface: oklch(99% 0.004 65);
--forge-border: #cbcbcb; --forge-surface-raised: oklch(100% 0.002 65);
--forge-accent: #946200; --forge-border: oklch(82% 0.012 65);
--forge-text: #252525; --forge-accent: oklch(50% 0.155 65);
--forge-text-secondary: #666666; --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 { body {
background-color: var(--forge-bg); background-color: var(--forge-bg);
color: var(--forge-text); 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: { forge: {
bg: "var(--forge-bg)", bg: "var(--forge-bg)",
surface: "var(--forge-surface)", surface: "var(--forge-surface)",
"surface-raised": "var(--forge-surface-raised)",
border: "var(--forge-border)", border: "var(--forge-border)",
accent: "var(--forge-accent)", 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: "var(--forge-text)",
"text-secondary": "var(--forge-text-secondary)", "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: { fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"], sans: ["Manrope Variable", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"], heading: ["Alegreya Variable", "Georgia", "serif"],
serif: ["Baskerville", "Georgia", "Palatino", "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)",
}, },
}, },
}, },