diff --git a/Dockerfile b/Dockerfile index cf37cff..cdad17a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ COPY default-rules default-rules COPY default-userscripts default-userscripts EXPOSE 8080 -ENTRYPOINT [ "python", "-u", "src/launcher.py" ] +ENTRYPOINT [ "python", "-u", "src/restartable_launcher.py" ] diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..2972165 --- /dev/null +++ b/admin-ui/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.temp/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/admin-ui/Dockerfile b/admin-ui/Dockerfile new file mode 100644 index 0000000..f58505b --- /dev/null +++ b/admin-ui/Dockerfile @@ -0,0 +1,41 @@ +FROM node:24.14.0-bookworm-slim AS builder + +WORKDIR /build + +COPY package.json package-lock.json ./ +COPY core/package.json ./core/ +COPY backend/package.json ./backend/ +COPY frontend/package.json ./frontend/ +RUN npm ci --no-audit --no-fund + +COPY tsconfig.base.json ./ +COPY core/tsconfig.json ./core/ +COPY backend/tsconfig.json ./backend/ +COPY frontend/tsconfig.json ./frontend/ +COPY frontend/tsconfig.app.json ./frontend/ +COPY frontend/tsconfig.node.json ./frontend/ +COPY eslint.config.js ./ +COPY frontend/vite.config.ts ./frontend/ +COPY frontend/index.html ./frontend/ + +COPY core/src/ ./core/src/ +COPY backend/src/ ./backend/src/ +COPY frontend/src/ ./frontend/src/ +RUN npm run build +RUN npm run lint + + +FROM node:24.14.0-bookworm-slim AS the-backend + +WORKDIR /app + +COPY --from=builder /build/backend/dist/ . + +EXPOSE 3000 + +CMD ["node", "index.cjs"] + + +FROM nginx:1.29.6-alpine3.23 AS the-frontend + +COPY --from=builder /build/frontend/dist/ /usr/share/nginx/html diff --git a/admin-ui/backend/package.json b/admin-ui/backend/package.json new file mode 100644 index 0000000..8ef4be8 --- /dev/null +++ b/admin-ui/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "@userscript-proxy/backend", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --build tsconfig.json && esbuild --tsconfig=tsconfig.json --bundle src/index.ts --outdir=dist --platform=node --out-extension:.js=.cjs --minify", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "@userscript-proxy/core": "*", + "fastify": "^5.8.2" + } +} diff --git a/admin-ui/backend/src/app.ts b/admin-ui/backend/src/app.ts new file mode 100644 index 0000000..fe97001 --- /dev/null +++ b/admin-ui/backend/src/app.ts @@ -0,0 +1,25 @@ +import cors from "@fastify/cors"; +import Fastify from "fastify"; +import { registerScriptRoutes } from "./routes/scripts"; +import { InMemoryScriptStore } from "./services/script-store"; + +export async function buildApp() { + const app = Fastify({ + logger: true, + }); + + await app.register(cors, { + origin: true, + methods: ["GET", "PUT", "POST", "DELETE"], + }); + + app.get("/api/health", () => { + return { ok: true }; + }); + + const scriptStore = new InMemoryScriptStore(); + + registerScriptRoutes(app, scriptStore); + + return app; +} diff --git a/admin-ui/backend/src/index.ts b/admin-ui/backend/src/index.ts new file mode 100644 index 0000000..187dfac --- /dev/null +++ b/admin-ui/backend/src/index.ts @@ -0,0 +1,18 @@ +import { buildApp } from "./app"; + +const port = Number(process.env["USERSCRIPT_PROXY_ADMIN_BACKEND_PORT"] ?? "3000"); +const host = process.env["USERSCRIPT_PROXY_ADMIN_BACKEND_HOST"] ?? "0.0.0.0"; + +async function main(): Promise { + const app = await buildApp(); + + try { + await app.listen({ port, host }); + app.log.info(`Backend listening on http://${host}:${port}`); + } catch (error) { + app.log.error(error); + process.exit(1); + } +} + +void main(); diff --git a/admin-ui/backend/src/routes/scripts.ts b/admin-ui/backend/src/routes/scripts.ts new file mode 100644 index 0000000..5a09ab7 --- /dev/null +++ b/admin-ui/backend/src/routes/scripts.ts @@ -0,0 +1,135 @@ +import type { FastifyInstance } from "fastify"; +import { isSafeScriptId } from "@userscript-proxy/core/script-id"; +import { + InvalidScriptSourceError, + ScriptAlreadyExistsError, + ScriptNotFoundError, + type ScriptStore, +} from "../services/script-store.js"; + +type ScriptIdParams = { + id: string; +}; + +type CreateScriptBody = { + id: string; + source: string; +}; + +type SaveSourceBody = { + source: string; +}; + +type SetEnabledBody = { + enabled: boolean; +}; + +export function registerScriptRoutes( + app: FastifyInstance, + scriptStore: ScriptStore, +): void { + app.get("/api/scripts", async (_request, reply) => { + const scripts = await scriptStore.listScripts(); + return reply.send({ scripts }); + }); + + app.get<{ Params: ScriptIdParams }>( + "/api/scripts/:id", + async (request, reply) => { + const script = await scriptStore.getScript(request.params.id); + + if (script === null) { + return reply.code(404).send({ + error: `Script not found: ${request.params.id}`, + }); + } + + return reply.send({ script }); + }, + ); + + app.post<{ Body: CreateScriptBody }>( + "/api/scripts", + async (request, reply) => { + if (!isSafeScriptId(request.body.id)) { + return reply + .code(400) + .send({ error: `Invalid script ID: ${request.body.id}` }); + } + + try { + const script = await scriptStore.createScript( + request.body.id, + request.body.source, + ); + return await reply.code(201).send({ script }); + } catch (error) { + if (error instanceof ScriptAlreadyExistsError) { + return reply.code(409).send({ error: error.message }); + } + + if (error instanceof InvalidScriptSourceError) { + return reply.code(400).send({ error: error.message }); + } + + throw error; + } + }, + ); + + app.put<{ Params: ScriptIdParams; Body: SaveSourceBody }>( + "/api/scripts/:id/source", + async (request, reply) => { + try { + const script = await scriptStore.saveSource( + request.params.id, + request.body.source, + ); + + return await reply.send({ script }); + } catch (error) { + if (error instanceof ScriptNotFoundError) { + return reply.code(404).send({ error: error.message }); + } + + if (error instanceof InvalidScriptSourceError) { + return reply.code(400).send({ error: error.message }); + } + + throw error; + } + }, + ); + + app.delete<{ Params: ScriptIdParams }>( + "/api/scripts/:id", + async (request, reply) => { + try { + await scriptStore.deleteScript(request.params.id); + return await reply.code(204).send(); + } catch (error) { + if (error instanceof ScriptNotFoundError) { + return reply.code(404).send({ error: error.message }); + } + + throw error; + } + }, + ); + + app.put<{ Params: ScriptIdParams; Body: SetEnabledBody }>( + "/api/scripts/:id/enabled", + async (request, reply) => { + try { + await scriptStore.setEnabled(request.params.id, request.body.enabled); + return await reply.code(204).send(); + } catch (error) { + if (error instanceof ScriptNotFoundError) { + return reply.code(404).send({ error: error.message }); + } + + throw error; + } + }, + ); +} diff --git a/admin-ui/backend/src/services/script-store.ts b/admin-ui/backend/src/services/script-store.ts new file mode 100644 index 0000000..789ba2e --- /dev/null +++ b/admin-ui/backend/src/services/script-store.ts @@ -0,0 +1,209 @@ +import { extractMetadata } from "@userscript-proxy/core/metadata"; + +export type ScriptRecord = { + id: string; + enabled: boolean; + source: string; +}; + +export type ScriptSummary = { + id: string; + enabled: boolean; + name: string; + version: string | null; +}; + +export type ScriptDetails = { + id: string; + enabled: boolean; + source: string; + name: string; + version: string | null; +}; + +export type ScriptStore = { + listScripts(): Promise>; + getScript(id: string): Promise; + createScript(id: string, source: string): Promise; + saveSource(id: string, source: string): Promise; + setEnabled(id: string, enabled: boolean): Promise; + deleteScript(id: string): Promise; +}; + +export class InMemoryScriptStore implements ScriptStore { + private scripts: Array; + + public constructor() { + this.scripts = [ + { + id: "example-script", + enabled: true, + source: `// ==UserScript== +// @name Example script +// @version 0.1.0 +// @match *://*/* +// ==/UserScript== + +console.log("hello"); +`, + }, + ]; + } + + public async listScripts(): Promise> { + await Promise.resolve(); + return this.scripts.map((script) => this.toSummary(script)); + } + + public async getScript(id: string): Promise { + await Promise.resolve(); + const script = this.scripts.find((candidate) => candidate.id === id); + + if (script === undefined) { + return null; + } + + return this.toDetails(script); + } + + public async createScript(id: string, source: string): Promise { + await Promise.resolve(); + const parseResult = extractMetadata(source); + + if (parseResult.tag === "Err") { + throw new InvalidScriptSourceError(parseResult.error); + } + + const exists = this.scripts.some((candidate) => candidate.id === id); + + if (exists) { + throw new ScriptAlreadyExistsError(id); + } + + const record: ScriptRecord = { id, enabled: true, source }; + this.scripts.push(record); + return this.toDetails(record); + } + + public async saveSource(id: string, source: string): Promise { + await Promise.resolve(); + const parseResult = extractMetadata(source); + + if (parseResult.tag === "Err") { + throw new InvalidScriptSourceError(parseResult.error); + } + + const existingIndex = this.scripts.findIndex( + (candidate) => candidate.id === id, + ); + + if (existingIndex === -1) { + throw new ScriptNotFoundError(id); + } + + const current = this.scripts[existingIndex]; + if (current === undefined) { + throw new ScriptNotFoundError(id); + } + + const updated: ScriptRecord = { + ...current, + source, + }; + + this.scripts[existingIndex] = updated; + + return this.toDetails(updated); + } + + public async setEnabled(id: string, enabled: boolean): Promise { + await Promise.resolve(); + const existingIndex = this.scripts.findIndex( + (candidate) => candidate.id === id, + ); + + if (existingIndex === -1) { + throw new ScriptNotFoundError(id); + } + + const current = this.scripts[existingIndex]; + if (current === undefined) { + throw new ScriptNotFoundError(id); + } + + this.scripts[existingIndex] = { + ...current, + enabled, + }; + } + + public async deleteScript(id: string): Promise { + await Promise.resolve(); + const existingIndex = this.scripts.findIndex( + (candidate) => candidate.id === id, + ); + + if (existingIndex === -1) { + throw new ScriptNotFoundError(id); + } + + this.scripts.splice(existingIndex, 1); + } + + private toSummary(script: ScriptRecord): ScriptSummary { + const parseResult = extractMetadata(script.source); + + if (parseResult.tag === "Err") { + return { + id: script.id, + enabled: script.enabled, + name: "(invalid script)", + version: null, + }; + } + + return { + id: script.id, + enabled: script.enabled, + name: parseResult.value.name, + version: parseResult.value.version, + }; + } + + private toDetails(script: ScriptRecord): ScriptDetails { + const parseResult = extractMetadata(script.source); + + if (parseResult.tag === "Err") { + throw new InvalidScriptSourceError(parseResult.error); + } + + return { + id: script.id, + enabled: script.enabled, + source: script.source, + name: parseResult.value.name, + version: parseResult.value.version, + }; + } +} + +export class ScriptNotFoundError extends Error { + public constructor(id: string) { + super(`Script not found: ${id}`); + this.name = "ScriptNotFoundError"; + } +} + +export class ScriptAlreadyExistsError extends Error { + public constructor(id: string) { + super(`Script already exists: ${id}`); + this.name = "ScriptAlreadyExistsError"; + } +} + +export class InvalidScriptSourceError extends Error { + public constructor(message: string) { + super(message); + this.name = "InvalidScriptSourceError"; + } +} diff --git a/admin-ui/backend/tsconfig.json b/admin-ui/backend/tsconfig.json new file mode 100644 index 0000000..d7d7dc8 --- /dev/null +++ b/admin-ui/backend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "tsBuildInfoFile": "./.temp/tsconfig.tsbuildinfo", + "types": ["node"], + }, + "include": ["src"], +} diff --git a/admin-ui/core/package.json b/admin-ui/core/package.json new file mode 100644 index 0000000..ce153ba --- /dev/null +++ b/admin-ui/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@userscript-proxy/core", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "dependencies": { + "tiny-decoders": "^24.0.0" + } +} diff --git a/admin-ui/core/src/assertions.ts b/admin-ui/core/src/assertions.ts new file mode 100644 index 0000000..3a9d5fc --- /dev/null +++ b/admin-ui/core/src/assertions.ts @@ -0,0 +1,24 @@ +export function assertExhausted(x: never, description: string): never { + throw new TypeError( + `assertExhausted: ${description} was unexpectedly ${JSON.stringify(x)}`, + ); +} + +export function throwIfNullOrUndefined( + x: T, + descriptionOfIt: DescriptionOrRedundanceHint, +): NonNullable { + if (x == null) { + throw new TypeError( + `throwIfNullOrUndefined: ${descriptionOfIt} was unexpectedly ${JSON.stringify(x)}.`, + ); + } + + return x; +} + +type DescriptionOrRedundanceHint = null extends T + ? string + : undefined extends T + ? string + : `It cannot be null or undefined; this check is redundant.`; diff --git a/admin-ui/core/src/decoding.ts b/admin-ui/core/src/decoding.ts new file mode 100644 index 0000000..9364714 --- /dev/null +++ b/admin-ui/core/src/decoding.ts @@ -0,0 +1,23 @@ +import * as td from "tiny-decoders"; + +export class DecodingError extends Error { + public constructor(error: td.DecoderError, context: string, dataIsSensitive: boolean) { + super(`Unexpected data in ${context}: ${td.format(error, { sensitive: dataIsSensitive })}`); + this.name = "DecodingError"; + } +} + +export function decodeOrThrow(namedParameters: { + codec: td.Codec, + data: unknown, + context: string, + dataIsSensitive: boolean, +}): T { + const result = namedParameters.codec.decoder(namedParameters.data); + + if (result.tag === "DecoderError") { + throw new DecodingError(result.error, namedParameters.context, namedParameters.dataIsSensitive); + } + + return result.value; +} diff --git a/admin-ui/core/src/metadata.ts b/admin-ui/core/src/metadata.ts new file mode 100644 index 0000000..95647e9 --- /dev/null +++ b/admin-ui/core/src/metadata.ts @@ -0,0 +1,54 @@ +import { Err, Ok, type Result } from "./results"; + +const PREFIX_NAME = "// @name"; +const PREFIX_VERSION = "// @version"; + +export type Metadata = { + name: string; + version: string | null; +}; + +export function extractMetadata(source: string): Result { + const metadataLines = extractMetadataLines(source); + + type ParsedMetadata = { + name: string | null; + version: string | null; + }; + + const initialMetadata: ParsedMetadata = { + name: null, + version: null, + }; + + const parsedMetadata = metadataLines.reduce((acc, line) => { + switch (true) { + case line.startsWith(PREFIX_NAME): + return { ...acc, name: line.slice(PREFIX_NAME.length).trim() }; + + case line.startsWith(PREFIX_VERSION): + return { ...acc, version: line.slice(PREFIX_VERSION.length).trim() }; + + default: + return acc; + } + }, initialMetadata); + + if (parsedMetadata.name === null || parsedMetadata.name === "") { + return Err(`Userscript must have a name.`); + } + + return Ok({ + name: parsedMetadata.name, + version: parsedMetadata.version, + }); +} + +function extractMetadataLines(sourceCode: string): Array { + const lines = sourceCode.split(/\r?\n/); + + const startIndex = lines.findIndex((x) => x.trim() === "// ==UserScript=="); + const endIndex = lines.findIndex((x) => x.trim() === "// ==/UserScript=="); + + return lines.slice(startIndex + 1, endIndex); +} diff --git a/admin-ui/core/src/results.ts b/admin-ui/core/src/results.ts new file mode 100644 index 0000000..27cda63 --- /dev/null +++ b/admin-ui/core/src/results.ts @@ -0,0 +1,13 @@ +export type Result = Ok | Err; + +export type Ok = { tag: "Ok"; value: T }; + +export type Err = { tag: "Err"; error: E }; + +export function Ok(value: T): Ok { + return { tag: "Ok", value }; +} + +export function Err(error: E): Err { + return { tag: "Err", error }; +} diff --git a/admin-ui/core/src/script-id.ts b/admin-ui/core/src/script-id.ts new file mode 100644 index 0000000..fa40046 --- /dev/null +++ b/admin-ui/core/src/script-id.ts @@ -0,0 +1,7 @@ +export function isSafeScriptId(id: string): boolean { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id); +} + +export function slugifyScriptName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} diff --git a/admin-ui/core/src/strings.ts b/admin-ui/core/src/strings.ts new file mode 100644 index 0000000..b2f75f8 --- /dev/null +++ b/admin-ui/core/src/strings.ts @@ -0,0 +1,3 @@ +export function quote(s: string): string { + return JSON.stringify(s); +} diff --git a/admin-ui/core/tsconfig.json b/admin-ui/core/tsconfig.json new file mode 100644 index 0000000..1c30a0e --- /dev/null +++ b/admin-ui/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + }, + "include": ["src"], +} diff --git a/admin-ui/eslint.config.js b/admin-ui/eslint.config.js new file mode 100644 index 0000000..accea31 --- /dev/null +++ b/admin-ui/eslint.config.js @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + parserOptions: { + project: [ + "./core/tsconfig.json", + "./backend/tsconfig.json", + "./frontend/tsconfig.node.json", + "./frontend/tsconfig.app.json", + ], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/array-type": [ + "error", + { default: "generic", readonly: "generic" }, + ], + "@typescript-eslint/consistent-type-definitions": ["error", "type"], + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowNumber: true, + }, + ], + }, + }, +]); diff --git a/admin-ui/frontend/README.md b/admin-ui/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/admin-ui/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/admin-ui/frontend/index.html b/admin-ui/frontend/index.html new file mode 100644 index 0000000..5c775f3 --- /dev/null +++ b/admin-ui/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Userscript Proxy + + +
+ + + diff --git a/admin-ui/frontend/package.json b/admin-ui/frontend/package.json new file mode 100644 index 0000000..28395b6 --- /dev/null +++ b/admin-ui/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "@userscript-proxy/frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --build tsconfig.json && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@userscript-proxy/core": "*", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tiny-decoders": "^24.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/admin-ui/frontend/public/favicon.svg b/admin-ui/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/admin-ui/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/frontend/src/App.css b/admin-ui/frontend/src/App.css new file mode 100644 index 0000000..83dfa41 --- /dev/null +++ b/admin-ui/frontend/src/App.css @@ -0,0 +1,191 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Arial, sans-serif; + background: #f5f5f5; + color: #222; +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.app { + min-height: 100vh; + padding: 24px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.topbar h1 { + margin: 0 0 4px; +} + +.topbar p { + margin: 0; + color: #666; +} + +.buttonGroup { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.listMode { + display: block; +} + +.scriptCards { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 16px; +} + +.scriptCard { + background: white; + border-radius: 12px; + padding: 16px; + border: 1px solid #ddd; +} + +.scriptCardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.scriptCardTitle { + font-size: 18px; + font-weight: bold; + margin-bottom: 4px; +} + +.scriptCardMeta { + color: #666; + font-size: 14px; +} + +.scriptCardActions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.inlineCheckbox { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.editMode { + display: grid; + grid-template-columns: 1fr 320px; + gap: 24px; +} + +.editorPanel, +.diagnosticsPanel, +.emptyState { + background: white; + border-radius: 12px; + padding: 16px; +} + +.formRow { + display: flex; + flex-direction: column; + gap: 8px; +} + +.formRow textarea { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: monospace; + resize: vertical; +} + +.errorBox { + padding: 10px; + border: 1px solid #d92d20; + border-radius: 8px; + background: #fef3f2; + color: #b42318; +} + +.warningBox { + padding: 10px; + border: 1px solid #f79009; + border-radius: 8px; + background: #fffaeb; + color: #b54708; +} + +.warningTitle { + font-weight: bold; + margin-bottom: 8px; +} + +.warningBox ul { + margin: 0; + padding-left: 20px; +} + +.okBox { + padding: 10px; + border: 1px solid #12b76a; + border-radius: 8px; + background: #ecfdf3; + color: #027a48; +} + +.diagnosticsPanel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.diagnosticsPanel h2 { + margin: 0; +} + +.dangerButton { + background: #b42318; + color: white; + border: none; + padding: 10px 14px; + border-radius: 8px; +} + +@media (max-width: 900px) { + .editMode { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/admin-ui/frontend/src/App.tsx b/admin-ui/frontend/src/App.tsx new file mode 100644 index 0000000..383071e --- /dev/null +++ b/admin-ui/frontend/src/App.tsx @@ -0,0 +1,449 @@ +import { useEffect, useState } from "react"; +import { assertExhausted } from "@userscript-proxy/core/assertions"; +import { extractMetadata } from "@userscript-proxy/core/metadata"; +import "./App.css"; +import { EditScriptView, type WhatIsBeingEdited } from "./EditScriptView"; +import { ListScriptsView } from "./ListScriptsView"; +import { NewScriptFormView } from "./NewScriptFormView"; +import { + createScript, + deleteScript, + getScript, + listScripts, + saveScriptSource, + setScriptEnabled, + type ScriptDetails, +} from "./api"; +import { makeNewScriptSource, type Script } from "./userscript"; + +type UiState = + | { + tag: "Loading"; + } + | { + tag: "LoadFailed"; + error: string; + } + | { + tag: "ListScripts"; + scripts: ReadonlyArray