diff --git a/.gitignore b/.gitignore
index 4274b51..5dc77dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ dist/
.env
*.log
.DS_Store
+*.tsbuildinfo
diff --git a/package-lock.json b/package-lock.json
index ca69a3a..19ae112 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -860,6 +860,29 @@
"resolved": "packages/frontend",
"link": true
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+ "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+ "license": "MIT",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.5.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -1567,6 +1590,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2271,6 +2301,15 @@
"node": ">= 8.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+ "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2848,6 +2887,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/marked": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+ "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2909,6 +2960,16 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
+ "node_modules/monaco-editor": {
+ "version": "0.55.1",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
+ "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "3.2.7",
+ "marked": "14.0.0"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3600,6 +3661,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4719,6 +4786,8 @@
"name": "@layonara-forge/frontend",
"version": "0.0.1",
"dependencies": {
+ "@monaco-editor/react": "^4.7.0",
+ "monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 034ee44..aff636b 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@monaco-editor/react": "^4.7.0",
+ "monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx
index 21c5b7f..fb4249d 100644
--- a/packages/frontend/src/App.tsx
+++ b/packages/frontend/src/App.tsx
@@ -1,11 +1,13 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Dashboard } from "./pages/Dashboard";
+import { Editor } from "./pages/Editor";
export function App() {
return (
} />
+ } />
);
diff --git a/packages/frontend/src/components/editor/EditorTabs.tsx b/packages/frontend/src/components/editor/EditorTabs.tsx
new file mode 100644
index 0000000..437ba4b
--- /dev/null
+++ b/packages/frontend/src/components/editor/EditorTabs.tsx
@@ -0,0 +1,74 @@
+interface Tab {
+ path: string;
+ dirty: boolean;
+}
+
+interface EditorTabsProps {
+ tabs: Tab[];
+ activeTab: string | null;
+ onSelect: (path: string) => void;
+ onClose: (path: string) => void;
+}
+
+function filename(path: string): string {
+ return path.split("/").pop() ?? path;
+}
+
+export function EditorTabs({
+ tabs,
+ activeTab,
+ onSelect,
+ onClose,
+}: EditorTabsProps) {
+ if (tabs.length === 0) return null;
+
+ return (
+
+ {tabs.map((tab) => {
+ const isActive = tab.path === activeTab;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/frontend/src/components/editor/MonacoEditor.tsx b/packages/frontend/src/components/editor/MonacoEditor.tsx
new file mode 100644
index 0000000..d00fe2b
--- /dev/null
+++ b/packages/frontend/src/components/editor/MonacoEditor.tsx
@@ -0,0 +1,278 @@
+import { useRef, useCallback } from "react";
+import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
+import type { editor } from "monaco-editor";
+
+interface MonacoEditorProps {
+ filePath: string;
+ content: string;
+ language?: string;
+ onChange?: (value: string) => void;
+}
+
+let nwscriptRegistered = false;
+
+function registerNWScript(monaco: Parameters[1]) {
+ if (nwscriptRegistered) return;
+ nwscriptRegistered = true;
+
+ monaco.languages.register({ id: "nwscript", extensions: [".nss"] });
+
+ monaco.languages.setMonarchTokensProvider("nwscript", {
+ keywords: [
+ "void",
+ "int",
+ "float",
+ "string",
+ "object",
+ "effect",
+ "itemproperty",
+ "location",
+ "vector",
+ "action",
+ "talent",
+ "event",
+ "struct",
+ "if",
+ "else",
+ "while",
+ "for",
+ "do",
+ "switch",
+ "case",
+ "default",
+ "break",
+ "continue",
+ "return",
+ "const",
+ ],
+
+ constants: ["TRUE", "FALSE", "OBJECT_SELF", "OBJECT_INVALID"],
+
+ typeKeywords: [
+ "void",
+ "int",
+ "float",
+ "string",
+ "object",
+ "effect",
+ "itemproperty",
+ "location",
+ "vector",
+ "action",
+ "talent",
+ "event",
+ "struct",
+ ],
+
+ operators: [
+ "=",
+ ">",
+ "<",
+ "!",
+ "~",
+ "?",
+ ":",
+ "==",
+ "<=",
+ ">=",
+ "!=",
+ "&&",
+ "||",
+ "++",
+ "--",
+ "+",
+ "-",
+ "*",
+ "/",
+ "&",
+ "|",
+ "^",
+ "%",
+ "<<",
+ ">>",
+ "+=",
+ "-=",
+ "*=",
+ "/=",
+ "&=",
+ "|=",
+ "^=",
+ "%=",
+ ],
+
+ symbols: /[=>[1]) {
+ const style = getComputedStyle(document.documentElement);
+ const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
+ const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
+ const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
+ const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
+ const textSecondary =
+ style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
+ const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
+
+ monaco.editor.defineTheme("forge-dark", {
+ base: "vs-dark",
+ inherit: true,
+ rules: [
+ { token: "keyword", foreground: "C586C0" },
+ { token: "keyword.preprocessor", foreground: "569CD6" },
+ { token: "type", foreground: "4EC9B0" },
+ { token: "constant", foreground: "4FC1FF" },
+ { token: "string", foreground: "CE9178" },
+ { token: "comment", foreground: "6A9955" },
+ { token: "number", foreground: "B5CEA8" },
+ { token: "operator", foreground: "D4D4D4" },
+ ],
+ colors: {
+ "editor.background": bg,
+ "editor.foreground": text,
+ "editorCursor.foreground": accent,
+ "editor.lineHighlightBackground": surface,
+ "editorLineNumber.foreground": textSecondary,
+ "editorLineNumber.activeForeground": text,
+ "editor.selectionBackground": "#264f7840",
+ "editorWidget.background": surface,
+ "editorWidget.border": border,
+ "editorSuggestWidget.background": surface,
+ "editorSuggestWidget.border": border,
+ },
+ });
+}
+
+function languageFromPath(filePath: string): string {
+ const ext = filePath.split(".").pop()?.toLowerCase();
+ const map: Record = {
+ nss: "nwscript",
+ json: "json",
+ sql: "sql",
+ css: "css",
+ html: "html",
+ xml: "xml",
+ js: "javascript",
+ ts: "typescript",
+ md: "markdown",
+ txt: "plaintext",
+ "2da": "plaintext",
+ };
+ return map[ext ?? ""] ?? "plaintext";
+}
+
+export function MonacoEditor({
+ filePath,
+ content,
+ language,
+ onChange,
+}: MonacoEditorProps) {
+ const editorRef = useRef(null);
+
+ const handleMount: OnMount = useCallback(
+ (editorInstance, monaco) => {
+ editorRef.current = editorInstance;
+ registerNWScript(monaco);
+ defineForgeTheme(monaco);
+ monaco.editor.setTheme("forge-dark");
+
+ const model = editorInstance.getModel();
+ if (model) {
+ const lang = language ?? languageFromPath(filePath);
+ monaco.editor.setModelLanguage(model, lang);
+ }
+ },
+ [filePath, language],
+ );
+
+ const handleChange = useCallback(
+ (value: string | undefined) => {
+ if (value !== undefined) {
+ onChange?.(value);
+ }
+ },
+ [onChange],
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/hooks/useEditorState.ts b/packages/frontend/src/hooks/useEditorState.ts
new file mode 100644
index 0000000..f179a83
--- /dev/null
+++ b/packages/frontend/src/hooks/useEditorState.ts
@@ -0,0 +1,128 @@
+import { useState, useCallback, useEffect, useRef } from "react";
+
+const STORAGE_KEY = "forge-editor-state";
+
+interface PersistedState {
+ openTabs: string[];
+ activeTab: string | null;
+}
+
+function loadPersistedState(): PersistedState {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ return {
+ openTabs: Array.isArray(parsed.openTabs) ? parsed.openTabs : [],
+ activeTab:
+ typeof parsed.activeTab === "string" ? parsed.activeTab : null,
+ };
+ }
+ } catch {
+ // corrupted storage — start fresh
+ }
+ return { openTabs: [], activeTab: null };
+}
+
+function persistState(state: PersistedState) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ // quota exceeded — silently ignore
+ }
+}
+
+export function useEditorState() {
+ const [openTabs, setOpenTabs] = useState(
+ () => loadPersistedState().openTabs,
+ );
+ const [activeTab, setActiveTab] = useState(
+ () => loadPersistedState().activeTab,
+ );
+ const [dirtyFiles, setDirtyFiles] = useState>(new Set());
+ const fileContents = useRef