From 85379f42f3817aa56fdd7bb75a67d45832d05c98 Mon Sep 17 00:00:00 2001 From: Jacob Mathison Date: Fri, 17 Jul 2026 15:02:08 -0500 Subject: [PATCH] feat: add workspace-aware extension dashboard --- AGENTS.md | 3 + README.md | 4 + index.html | 67 +++--- llm.txt | 4 + manifest.llm.json | 36 +++ package-lock.json | 7 + package.json | 2 + public/local-context.json | 306 ++++++++++++++++++++++++++ scripts/generate-local-context.mjs | 158 ++++++++++++++ src/api.ts | 122 ++++++++++- src/localContext.ts | 50 +++++ src/main.ts | 306 ++++++++++++++++++++++++-- src/store.ts | 59 ++++- src/styles.css | 339 +++++++++++++++++++++++++---- 14 files changed, 1369 insertions(+), 94 deletions(-) create mode 100644 public/local-context.json create mode 100644 scripts/generate-local-context.mjs create mode 100644 src/localContext.ts diff --git a/AGENTS.md b/AGENTS.md index 146ecd8..b669126 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,10 +37,13 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing - `dist/` is the deployable output directory. Deployment or Chrome load-unpacked flows should point directly at `dist/`. - `dist/` is generated output and should not be edited directly. - Static extension assets live in `public/`; the current favicon source is `public/favicon.svg`. +- `scripts/generate-local-context.mjs` generates `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father` before builds. +- `public/local-context.json` is a committed fallback snapshot so CI builds can still produce a self-contained `dist/` if the local workspace root is unavailable. - `npm run build` runs `scripts/verify-dist.mjs` after Vite to ensure the generated output has the required extension files. - Host permissions are derived from `VITE_GITEA_BASE_URL`. Rebuild after changing the base URL. - Runtime API calls are centralized in `src/api.ts`. - Popup state is centralized in the vanilla Zustand store at `src/store.ts`. +- The popup UI is a movable drag/drop window with icon menu sections for URLs, Gitea repos, and workspaces. Add URL and Add Issue are intentionally disabled. ## Chrome Extension Management diff --git a/README.md b/README.md index 7a54233..49c7196 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ The deployable extension output is `dist/`. Load `dist/` in Chrome as an unpacke Static assets, including the favicon, live in `public/` and are copied into `dist/` during the build. +The app opens as a draggable window inside the extension popup. The top icon menu includes URLs, Gitea repos, and workspaces; Add URL and Add Issue are present but disabled for now. + +Builds generate `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father`. That snapshot links known workspaces to local Gitea repos and is copied into `dist/` so the built extension is self-contained. + ## Extension Management See [docs/extension-management.md](docs/extension-management.md) before loading, reloading, testing, or automating the Chrome extension. diff --git a/index.html b/index.html index 4978324..6221a1c 100644 --- a/index.html +++ b/index.html @@ -7,33 +7,50 @@ Silma AI Aide -
-
-
-

Gitea

-

Silma AI Aide

-
- Checking -
+
+
+
+
+

Silma

+

AI Aide

+
+ Checking +
-
-
-
-
Base URL
-
Not configured
-
-
-
Token
-
Not configured
-
-
-
Target repo
-
Not configured
-
-
+ - -

Ready.

+
+
+
+
+ +
+ Ready. + +
diff --git a/llm.txt b/llm.txt index 37fc1bd..77f48b4 100644 --- a/llm.txt +++ b/llm.txt @@ -14,9 +14,13 @@ Important files: - `src/main.ts`: Popup UI behavior. - `index.html`: Popup document. - `public/favicon.svg`: Source favicon copied into `dist/` by Vite. +- `public/local-context.json`: Generated workspace/repo/URL context copied into `dist/`. +- `scripts/generate-local-context.mjs`: Scans `/Users/Tabitha/.openclaw/workspace_Father` and writes `public/local-context.json`. Build with `npm run build`. The deployable output directory is `dist/`; load that folder in Chrome as an unpacked extension or point deployment tooling at it. Do not edit `dist/` directly. +UI behavior: the app renders as a movable drag/drop window inside the browser surface. The menu items are Add URL (disabled), URLs, Gitea Repos, Workspaces, and Add Issue (disabled). Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos. + Extension management rule: use the Codex Chrome extension with the Chrome profile named `Silma`. Do not use `Profile 1`. Codex commands: diff --git a/manifest.llm.json b/manifest.llm.json index acb490e..b273257 100644 --- a/manifest.llm.json +++ b/manifest.llm.json @@ -15,10 +15,46 @@ "popup_script": "src/main.ts", "state_store": "src/store.ts", "api_client": "src/api.ts", + "local_context_types": "src/localContext.ts", "vite_config": "vite.config.ts", "favicon": "public/favicon.svg", + "local_context": "public/local-context.json", + "local_context_generator": "scripts/generate-local-context.mjs", "generated_manifest": "dist/manifest.json" }, + "ui": { + "shell": "movable drag/drop window inside the browser surface", + "menu_items": [ + { + "name": "Add URL", + "enabled": false + }, + { + "name": "URLs", + "enabled": true + }, + { + "name": "Gitea Repos", + "enabled": true + }, + { + "name": "Workspaces", + "enabled": true + }, + { + "name": "Add Issue", + "enabled": false + } + ], + "repo_ordering": "Gitea repos are ordered by latest default-branch commit timestamp.", + "known_urls": [ + { + "url": "http://100.66.226.22:23030/", + "workspace": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data", + "gitea_repo": "jacob-mathison/home-grown-llm-data" + } + ] + }, "documentation": { "agent_instructions": "AGENTS.md", "llm_summary": "llm.txt", diff --git a/package-lock.json b/package-lock.json index da89515..03cd5a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "axios": "^1.18.1", "dotenv": "^17.4.2", + "lucide": "^1.25.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -1375,6 +1376,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lucide": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.25.0.tgz", + "integrity": "sha512-Pg/9Ga1xTbrnI6GY/7J9krIYmJIkAf3PkK6wPmsAeUtAQsBdGxs1+dfvLqjf9nPI5aFm9+VquT75JGpO3QXSpQ==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index a414469..3f10185 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "codex:setup": "npm ci", "dev": "vite --host 0.0.0.0", "dev:codex": "npm run dev", + "prebuild": "node scripts/generate-local-context.mjs", "build": "tsc --noEmit && vite build && npm run verify:dist", "build:codex": "npm run build", "preview": "vite preview --host 0.0.0.0", @@ -22,6 +23,7 @@ "dependencies": { "axios": "^1.18.1", "dotenv": "^17.4.2", + "lucide": "^1.25.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/public/local-context.json b/public/local-context.json new file mode 100644 index 0000000..b43c896 --- /dev/null +++ b/public/local-context.json @@ -0,0 +1,306 @@ +{ + "generatedAt": "build-time-local-context", + "workspaceRoot": "/Users/Tabitha/.openclaw/workspace_Father", + "workspaces": [ + { + "name": "aarete", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-doczyai-app", + "worktreeRelativePath": "aarete/workspace/aarete-doczyai-app", + "fullName": "jacob-mathison/aarete-doczyai-app", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-app" + }, + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-query-orchestration", + "worktreeRelativePath": "aarete/workspace/aarete-query-orchestration", + "fullName": "jacob-mathison/query-orchestration", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/query-orchestration" + }, + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/doczyai-pipelines", + "worktreeRelativePath": "aarete/workspace/doczyai-pipelines", + "fullName": "jacob-mathison/doczyai-pipelines", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/doczyai-pipelines" + } + ] + }, + { + "name": "aarete-unified", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete-unified", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete-unified", + "worktreeRelativePath": "aarete-unified", + "fullName": "jacob-mathison/aarete-doczyai-unified", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-unified" + } + ] + }, + { + "name": "banner-and-pyre", + "path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre", + "worktreeRelativePath": "banner-and-pyre/mp-banner-and-pyre", + "fullName": "jacob-mathison/mp-banner-and-pyre", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre" + }, + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-migration", + "worktreeRelativePath": "banner-and-pyre/mp-banner-and-pyre-migration", + "fullName": "jacob-mathison/mp-banner-and-pyre-migration", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-migration" + }, + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-web", + "worktreeRelativePath": "banner-and-pyre/mp-banner-and-pyre-web", + "fullName": "jacob-mathison/mp-banner-and-pyre-web", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-web" + } + ] + }, + { + "name": "blb-throne-of-the-bone-king", + "path": "/Users/Tabitha/.openclaw/workspace_Father/blb-throne-of-the-bone-king", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/blb-throne-of-the-bone-king", + "worktreeRelativePath": "blb-throne-of-the-bone-king", + "fullName": "jacob-mathison/blb-throne-of-the-bone-king", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/blb-throne-of-the-bone-king" + } + ] + }, + { + "name": "midnights-eclipse-migration", + "path": "/Users/Tabitha/.openclaw/workspace_Father/midnights-eclipse-migration", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/midnights-eclipse-migration", + "worktreeRelativePath": "midnights-eclipse-migration", + "fullName": "jacob-mathison/midnights-eclipse-migration", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/midnights-eclipse-migration" + } + ] + }, + { + "name": "mp-silma-ai-aide", + "path": "/Users/Tabitha/.openclaw/workspace_Father/mp-silma-ai-aide", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/mp-silma-ai-aide", + "worktreeRelativePath": "mp-silma-ai-aide", + "fullName": "jacob-mathison/mp-silma-ai-aide", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-silma-ai-aide" + } + ] + }, + { + "name": "tuono", + "path": "/Users/Tabitha/.openclaw/workspace_Father/tuono", + "linkedRepos": [] + }, + { + "name": "workspace_Data", + "path": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data", + "linkedRepos": [ + { + "worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data", + "worktreeRelativePath": "workspace_Data", + "fullName": "jacob-mathison/home-grown-llm-data", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/home-grown-llm-data" + } + ] + } + ], + "gitWorktrees": [ + { + "name": "aarete-unified", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete-unified", + "relativePath": "aarete-unified", + "topWorkspaceName": "aarete-unified", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/aarete-doczyai-unified", + "owner": "jacob-mathison", + "name": "aarete-doczyai-unified", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-unified", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/aarete-doczyai-unified.git" + } + }, + { + "name": "automations", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/automations", + "relativePath": "aarete/automations", + "topWorkspaceName": "aarete", + "branch": "main", + "giteaRepo": null + }, + { + "name": "aarete-doczyai-app", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-doczyai-app", + "relativePath": "aarete/workspace/aarete-doczyai-app", + "topWorkspaceName": "aarete", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/aarete-doczyai-app", + "owner": "jacob-mathison", + "name": "aarete-doczyai-app", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-app", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/aarete-doczyai-app.git" + } + }, + { + "name": "aarete-query-orchestration", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-query-orchestration", + "relativePath": "aarete/workspace/aarete-query-orchestration", + "topWorkspaceName": "aarete", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/query-orchestration", + "owner": "jacob-mathison", + "name": "query-orchestration", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/query-orchestration", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/query-orchestration.git" + } + }, + { + "name": "doczyai-pipelines", + "path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/doczyai-pipelines", + "relativePath": "aarete/workspace/doczyai-pipelines", + "topWorkspaceName": "aarete", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/doczyai-pipelines", + "owner": "jacob-mathison", + "name": "doczyai-pipelines", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/doczyai-pipelines", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/doczyai-pipelines.git" + } + }, + { + "name": "mp-banner-and-pyre", + "path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre", + "relativePath": "banner-and-pyre/mp-banner-and-pyre", + "topWorkspaceName": "banner-and-pyre", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/mp-banner-and-pyre", + "owner": "jacob-mathison", + "name": "mp-banner-and-pyre", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-banner-and-pyre.git" + } + }, + { + "name": "mp-banner-and-pyre-migration", + "path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-migration", + "relativePath": "banner-and-pyre/mp-banner-and-pyre-migration", + "topWorkspaceName": "banner-and-pyre", + "branch": "master", + "giteaRepo": { + "fullName": "jacob-mathison/mp-banner-and-pyre-migration", + "owner": "jacob-mathison", + "name": "mp-banner-and-pyre-migration", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-migration", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-banner-and-pyre-migration.git" + } + }, + { + "name": "mp-banner-and-pyre-web", + "path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-web", + "relativePath": "banner-and-pyre/mp-banner-and-pyre-web", + "topWorkspaceName": "banner-and-pyre", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/mp-banner-and-pyre-web", + "owner": "jacob-mathison", + "name": "mp-banner-and-pyre-web", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-web", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-banner-and-pyre-web.git" + } + }, + { + "name": "blb-throne-of-the-bone-king", + "path": "/Users/Tabitha/.openclaw/workspace_Father/blb-throne-of-the-bone-king", + "relativePath": "blb-throne-of-the-bone-king", + "topWorkspaceName": "blb-throne-of-the-bone-king", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/blb-throne-of-the-bone-king", + "owner": "jacob-mathison", + "name": "blb-throne-of-the-bone-king", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/blb-throne-of-the-bone-king", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/blb-throne-of-the-bone-king.git" + } + }, + { + "name": "midnights-eclipse-migration", + "path": "/Users/Tabitha/.openclaw/workspace_Father/midnights-eclipse-migration", + "relativePath": "midnights-eclipse-migration", + "topWorkspaceName": "midnights-eclipse-migration", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/midnights-eclipse-migration", + "owner": "jacob-mathison", + "name": "midnights-eclipse-migration", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/midnights-eclipse-migration", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/midnights-eclipse-migration.git" + } + }, + { + "name": "mp-silma-ai-aide", + "path": "/Users/Tabitha/.openclaw/workspace_Father/mp-silma-ai-aide", + "relativePath": "mp-silma-ai-aide", + "topWorkspaceName": "mp-silma-ai-aide", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/mp-silma-ai-aide", + "owner": "jacob-mathison", + "name": "mp-silma-ai-aide", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-silma-ai-aide", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-silma-ai-aide.git" + } + }, + { + "name": "tuono", + "path": "/Users/Tabitha/.openclaw/workspace_Father/tuono", + "relativePath": "tuono", + "topWorkspaceName": "tuono", + "branch": "main", + "giteaRepo": null + }, + { + "name": "workspace_Data", + "path": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data", + "relativePath": "workspace_Data", + "topWorkspaceName": "workspace_Data", + "branch": "main", + "giteaRepo": { + "fullName": "jacob-mathison/home-grown-llm-data", + "owner": "jacob-mathison", + "name": "home-grown-llm-data", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/home-grown-llm-data", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/home-grown-llm-data.git" + } + } + ], + "knownUrls": [ + { + "label": "Automated Training Monitor", + "url": "http://100.66.226.22:23030/", + "workspacePath": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data", + "workspaceName": "workspace_Data", + "giteaRepo": { + "fullName": "jacob-mathison/home-grown-llm-data", + "owner": "jacob-mathison", + "name": "home-grown-llm-data", + "webUrl": "http://gitea.orson.tealthrone/jacob-mathison/home-grown-llm-data", + "cloneUrl": "http://100.79.253.19:3000/jacob-mathison/home-grown-llm-data.git" + } + } + ] +} diff --git a/scripts/generate-local-context.mjs b/scripts/generate-local-context.mjs new file mode 100644 index 0000000..f1770fa --- /dev/null +++ b/scripts/generate-local-context.mjs @@ -0,0 +1,158 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { basename, join, relative } from "node:path"; +import { execFileSync } from "node:child_process"; + +const workspaceRoot = + process.env.WORKSPACE_FATHER_PATH || "/Users/Tabitha/.openclaw/workspace_Father"; +const outputPath = join(process.cwd(), "public", "local-context.json"); +const giteaBaseUrl = "http://gitea.orson.tealthrone"; + +function runGit(args, cwd) { + try { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + return ""; + } +} + +function findGitWorktrees(root, maxDepth = 4) { + const found = []; + + function walk(dir, depth) { + if (depth > maxDepth) { + return; + } + + if (existsSync(join(dir, ".git"))) { + found.push(dir); + return; + } + + let entries = []; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") { + continue; + } + walk(join(dir, entry.name), depth + 1); + } + } + + walk(root, 0); + return found.sort(); +} + +function normalizeGiteaRemote(remoteUrl) { + if (!remoteUrl) { + return null; + } + + const match = remoteUrl.match( + /(?:gitea\.orson\.tealthrone|100\.79\.253\.19(?::3000)?)[/:]([^/]+)\/([^/.]+)(?:\.git)?$/ + ); + if (!match) { + return null; + } + + const [, owner, name] = match; + return { + fullName: `${owner}/${name}`, + owner, + name, + webUrl: `${giteaBaseUrl}/${owner}/${name}`, + cloneUrl: `http://100.79.253.19:3000/${owner}/${name}.git` + }; +} + +function repoForWorktree(path) { + const remoteNames = runGit(["remote"], path).split(/\s+/).filter(Boolean); + const orderedNames = ["gitea", "hagia", "origin", ...remoteNames].filter((name, index, names) => { + return remoteNames.includes(name) && names.indexOf(name) === index; + }); + + for (const remoteName of orderedNames) { + const repo = normalizeGiteaRemote(runGit(["remote", "get-url", remoteName], path)); + if (repo) { + return repo; + } + } + + return null; +} + +function buildContext() { + const topLevelWorkspaces = readdirSync(workspaceRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + path: join(workspaceRoot, entry.name) + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const gitWorktrees = findGitWorktrees(workspaceRoot).map((path) => { + const repo = repoForWorktree(path); + const topWorkspaceName = relative(workspaceRoot, path).split("/")[0] || basename(path); + return { + name: basename(path), + path, + relativePath: relative(workspaceRoot, path), + topWorkspaceName, + branch: runGit(["branch", "--show-current"], path) || null, + giteaRepo: repo + }; + }); + + const workspaces = topLevelWorkspaces.map((workspace) => ({ + ...workspace, + linkedRepos: gitWorktrees + .filter((worktree) => worktree.topWorkspaceName === workspace.name && worktree.giteaRepo) + .map((worktree) => ({ + worktreePath: worktree.path, + worktreeRelativePath: worktree.relativePath, + fullName: worktree.giteaRepo.fullName, + webUrl: worktree.giteaRepo.webUrl + })) + })); + + const workspaceData = gitWorktrees.find( + (worktree) => worktree.path === join(workspaceRoot, "workspace_Data") + ); + + return { + generatedAt: "build-time-local-context", + workspaceRoot, + workspaces, + gitWorktrees, + knownUrls: [ + { + label: "Automated Training Monitor", + url: "http://100.66.226.22:23030/", + workspacePath: join(workspaceRoot, "workspace_Data"), + workspaceName: "workspace_Data", + giteaRepo: workspaceData?.giteaRepo ?? { + fullName: "jacob-mathison/home-grown-llm-data", + webUrl: `${giteaBaseUrl}/jacob-mathison/home-grown-llm-data` + } + } + ] + }; +} + +mkdirSync(join(process.cwd(), "public"), { recursive: true }); + +if (!existsSync(workspaceRoot)) { + if (existsSync(outputPath)) { + console.log(`Workspace root missing; preserving ${outputPath}`); + process.exit(0); + } + throw new Error(`Workspace root does not exist: ${workspaceRoot}`); +} + +const context = buildContext(); +writeFileSync(outputPath, `${JSON.stringify(context, null, 2)}\n`); +console.log(`Wrote ${outputPath} with ${context.workspaces.length} workspaces`); diff --git a/src/api.ts b/src/api.ts index 98289f5..d295797 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,6 +12,46 @@ export interface GiteaUser { email?: string; } +interface GiteaRepoOwner { + login: string; +} + +interface GiteaRepoResponse { + name: string; + full_name?: string; + description?: string; + html_url?: string; + clone_url?: string; + ssh_url?: string; + updated_at?: string; + default_branch?: string; + private?: boolean; + owner?: GiteaRepoOwner; +} + +interface GiteaBranchResponse { + name: string; + commit?: { + id?: string; + message?: string; + timestamp?: string; + url?: string; + }; +} + +export interface GiteaRepoSummary { + name: string; + fullName: string; + description: string; + webUrl: string; + cloneUrl: string; + defaultBranch: string; + isPrivate: boolean; + lastCommitAt: string; + lastCommitSha: string; + lastCommitMessage: string; +} + export const giteaConfig = { baseUrl: rawGiteaBaseUrl, hasToken: giteaToken.length > 0, @@ -24,16 +64,34 @@ export const giteaConfig = { isConfigured: rawGiteaBaseUrl.length > 0 && giteaToken.length > 0 }; -function getApiBaseUrl(baseUrl: string): string { +export function getApiBaseUrl(baseUrl: string): string { const normalized = baseUrl.replace(/\/+$/, ""); return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`; } -export async function getCurrentGiteaUser(): Promise { +function assertGiteaConfigured(): void { if (!giteaConfig.isConfigured) { throw new Error("Set VITE_GITEA_BASE_URL and VITE_GITEA_TOKEN before testing the connection."); } +} +function createGiteaClient() { + assertGiteaConfigured(); + return axios.create({ + baseURL: getApiBaseUrl(giteaConfig.baseUrl), + headers: { + Authorization: `token ${giteaToken}` + } + }); +} + +export async function getCurrentGiteaUser(): Promise { + const client = createGiteaClient(); + const response = await client.get("/user"); + return response.data; +} + +export async function getReposOrderedByLastCommit(): Promise { const client = axios.create({ baseURL: getApiBaseUrl(giteaConfig.baseUrl), headers: { @@ -41,6 +99,62 @@ export async function getCurrentGiteaUser(): Promise { } }); - const response = await client.get("/user"); - return response.data; + assertGiteaConfigured(); + + const repos: GiteaRepoResponse[] = []; + let page = 1; + + while (page <= 10) { + const response = await client.get("/user/repos", { + params: { + limit: 50, + page + } + }); + + repos.push(...response.data); + + if (response.data.length < 50) { + break; + } + + page += 1; + } + + const repoDetails = await Promise.all( + repos.map(async (repo): Promise => { + const owner = repo.owner?.login || giteaConfig.repoOwner; + const defaultBranch = repo.default_branch || "main"; + const fullName = repo.full_name || `${owner}/${repo.name}`; + + let branch: GiteaBranchResponse | null = null; + try { + const branchResponse = await client.get( + `/repos/${owner}/${repo.name}/branches/${defaultBranch}` + ); + branch = branchResponse.data; + } catch { + branch = null; + } + + return { + name: repo.name, + fullName, + description: repo.description || "", + webUrl: repo.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${fullName}`, + cloneUrl: repo.clone_url || "", + defaultBranch, + isPrivate: Boolean(repo.private), + lastCommitAt: branch?.commit?.timestamp || repo.updated_at || "", + lastCommitSha: branch?.commit?.id || "", + lastCommitMessage: branch?.commit?.message?.split("\n")[0] || "No commit message available" + }; + }) + ); + + return repoDetails.sort((a, b) => { + const aTime = a.lastCommitAt ? Date.parse(a.lastCommitAt) : 0; + const bTime = b.lastCommitAt ? Date.parse(b.lastCommitAt) : 0; + return bTime - aTime || a.fullName.localeCompare(b.fullName); + }); } diff --git a/src/localContext.ts b/src/localContext.ts new file mode 100644 index 0000000..19d6463 --- /dev/null +++ b/src/localContext.ts @@ -0,0 +1,50 @@ +export interface LocalGiteaRepoLink { + fullName: string; + webUrl: string; +} + +export interface WorkspaceRepoLink extends LocalGiteaRepoLink { + worktreePath: string; + worktreeRelativePath: string; +} + +export interface LocalWorkspace { + name: string; + path: string; + linkedRepos: WorkspaceRepoLink[]; +} + +export interface LocalGitWorktree { + name: string; + path: string; + relativePath: string; + topWorkspaceName: string; + branch: string | null; + giteaRepo: LocalGiteaRepoLink | null; +} + +export interface KnownUrl { + label: string; + url: string; + workspaceName: string; + workspacePath: string; + giteaRepo: LocalGiteaRepoLink | null; +} + +export interface LocalContext { + generatedAt: string; + workspaceRoot: string; + workspaces: LocalWorkspace[]; + gitWorktrees: LocalGitWorktree[]; + knownUrls: KnownUrl[]; +} + +export async function loadLocalContext(): Promise { + const response = await fetch("/local-context.json", { cache: "no-store" }); + + if (!response.ok) { + throw new Error(`Could not load local context (${response.status}). Run npm run build.`); + } + + return response.json() as Promise; +} diff --git a/src/main.ts b/src/main.ts index 215d5a1..c1e361b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,29 +1,222 @@ +import { createIcons, CirclePlus, FolderGit2, GitBranch, Link, List, RefreshCw } from "lucide"; import "./styles.css"; -import { appStore } from "./store"; +import { appStore, type AppView } from "./store"; +import type { GiteaRepoSummary } from "./api"; +import type { KnownUrl, LocalWorkspace } from "./localContext"; +const appWindow = document.querySelector("#app-window"); +const dragHandle = document.querySelector("#window-drag-handle"); const configBadge = document.querySelector("#config-badge"); -const baseUrl = document.querySelector("#base-url"); -const tokenState = document.querySelector("#token-state"); -const targetRepo = document.querySelector("#target-repo"); const status = document.querySelector("#status"); -const testConnection = document.querySelector("#test-connection"); +const refreshData = document.querySelector("#refresh-data"); +const viewHeader = document.querySelector("#view-header"); +const viewContent = document.querySelector("#view-content"); +const viewButtons = Array.from(document.querySelectorAll("[data-view]")); -function setText(element: HTMLElement | null, text: string): void { - if (element) { - element.textContent = text; +createIcons({ + icons: { + CirclePlus, + FolderGit2, + GitBranch, + Link, + List, + RefreshCw } +}); + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + const replacements: Record = { + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'" + }; + return replacements[char]; + }); } -function render(): void { +function formatDate(value: string): string { + if (!value) { + return "No commit date"; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value)); +} + +function formatSha(value: string): string { + return value ? value.slice(0, 8) : "unknown"; +} + +function getRepoWorkspaceLink(repo: GiteaRepoSummary): string { + const context = appStore.getState().localContext; + const worktree = context?.gitWorktrees.find((item) => item.giteaRepo?.fullName === repo.fullName); + + if (!worktree) { + return `No workspace link`; + } + + return `Workspace link${escapeHtml(worktree.path)}`; +} + +function renderRepos(repos: GiteaRepoSummary[]): string { + if (!repos.length) { + return `
No Gitea repos loaded yet.
`; + } + + return repos + .map( + (repo) => ` +
+
+
+ ${escapeHtml(repo.fullName)} +

${escapeHtml(repo.description || "No description")}

+
+ ${repo.isPrivate ? "Private" : "Public"} +
+
+ Branch: ${escapeHtml(repo.defaultBranch)} + Last commit: ${formatDate(repo.lastCommitAt)} + SHA: ${formatSha(repo.lastCommitSha)} +
+

${escapeHtml(repo.lastCommitMessage)}

+
${getRepoWorkspaceLink(repo)}
+
+ ` + ) + .join(""); +} + +function renderWorkspaces(workspaces: LocalWorkspace[]): string { + if (!workspaces.length) { + return `
No workspaces found in workspace_Father.
`; + } + + return workspaces + .map((workspace) => { + const links = workspace.linkedRepos.length + ? workspace.linkedRepos + .map( + (repo) => + `${escapeHtml(repo.fullName)}` + ) + .join("") + : `No Gitea repo link established`; + + return ` +
+
+
+

${escapeHtml(workspace.name)}

+

${escapeHtml(workspace.path)}

+
+ ${workspace.linkedRepos.length ? `Linked` : ""} +
+ +
+ `; + }) + .join(""); +} + +function renderUrls(urls: KnownUrl[]): string { + if (!urls.length) { + return `
No known URLs registered.
`; + } + + return urls + .map( + (knownUrl) => ` + + ` + ) + .join(""); +} + +function openUrl(url: string): void { + if (globalThis.chrome?.tabs?.create) { + void globalThis.chrome.tabs.create({ url }); + return; + } + + window.open(url, "_blank", "noopener,noreferrer"); +} + +function renderView(): void { + if (!viewHeader || !viewContent) { + return; + } + + const state = appStore.getState(); + const context = state.localContext; + const titles: Record = { + urls: "URLs", + repos: "Gitea repos", + workspaces: "Workspaces" + }; + + const descriptions: Record = { + urls: "Known browser targets and their workspace/repo links.", + repos: "Repositories ordered by latest default-branch commit.", + workspaces: `Authoritative workspaces from ${context?.workspaceRoot || "workspace_Father"}.` + }; + + viewHeader.innerHTML = ` +
+

${titles[state.activeView]}

+

${escapeHtml(descriptions[state.activeView])}

+
+ `; + + if (state.activeView === "repos") { + viewContent.innerHTML = renderRepos(state.repos); + } else if (state.activeView === "workspaces") { + viewContent.innerHTML = renderWorkspaces(context?.workspaces ?? []); + } else { + viewContent.innerHTML = renderUrls(context?.knownUrls ?? []); + } + + viewContent.querySelectorAll("[data-open-url]").forEach((button) => { + button.addEventListener("click", () => { + const url = button.dataset.openUrl; + if (url) { + openUrl(url); + } + }); + }); +} + +function renderChrome(): void { const state = appStore.getState(); const config = state.config; - setText(baseUrl, config.baseUrl || "Not configured"); - setText(tokenState, config.hasToken ? "Configured" : "Not configured"); - setText(targetRepo, config.targetRepo || "Not configured"); - setText(configBadge, config.isConfigured ? "Configured" : "Missing env"); - if (configBadge) { + configBadge.textContent = config.isConfigured ? "Configured" : "Missing env"; configBadge.dataset.tone = config.isConfigured ? "success" : "warning"; } @@ -32,14 +225,87 @@ function render(): void { status.dataset.tone = state.statusTone; } - if (testConnection) { - testConnection.disabled = state.isTestingConnection; + if (refreshData) { + refreshData.disabled = state.isLoadingData; } + + if (appWindow) { + appWindow.style.transform = `translate(${state.windowPosition.x}px, ${state.windowPosition.y}px)`; + } + + viewButtons.forEach((button) => { + const view = button.dataset.view; + button.dataset.active = view === state.activeView ? "true" : "false"; + }); } +function render(): void { + renderChrome(); + renderView(); +} + +function setupDragging(): void { + if (!appWindow || !dragHandle) { + return; + } + + let dragStart: + | { + pointerId: number; + pointerX: number; + pointerY: number; + windowX: number; + windowY: number; + } + | null = null; + + dragHandle.addEventListener("pointerdown", (event) => { + const state = appStore.getState(); + dragStart = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + windowX: state.windowPosition.x, + windowY: state.windowPosition.y + }; + dragHandle.setPointerCapture(event.pointerId); + }); + + dragHandle.addEventListener("pointermove", (event) => { + if (!dragStart || event.pointerId !== dragStart.pointerId) { + return; + } + + const rect = appWindow.getBoundingClientRect(); + const maxX = Math.max(0, window.innerWidth - rect.width); + const maxY = Math.max(0, window.innerHeight - rect.height); + const x = Math.min(Math.max(0, dragStart.windowX + event.clientX - dragStart.pointerX), maxX); + const y = Math.min(Math.max(0, dragStart.windowY + event.clientY - dragStart.pointerY), maxY); + appStore.getState().setWindowPosition({ x, y }); + }); + + dragHandle.addEventListener("pointerup", (event) => { + if (dragStart?.pointerId === event.pointerId) { + dragStart = null; + dragHandle.releasePointerCapture(event.pointerId); + } + }); +} + +viewButtons.forEach((button) => { + button.addEventListener("click", () => { + const view = button.dataset.view as AppView | undefined; + if (view) { + appStore.getState().setActiveView(view); + } + }); +}); + +refreshData?.addEventListener("click", () => { + void appStore.getState().loadData(); +}); + +setupDragging(); appStore.subscribe(render); render(); - -testConnection?.addEventListener("click", () => { - void appStore.getState().testConnection(); -}); +void appStore.getState().loadData(); diff --git a/src/store.ts b/src/store.ts index c90af15..371bc92 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,23 +1,80 @@ import { createStore } from "zustand/vanilla"; -import { getCurrentGiteaUser, giteaConfig, type GiteaUser } from "./api"; +import { + getCurrentGiteaUser, + getReposOrderedByLastCommit, + giteaConfig, + type GiteaRepoSummary, + type GiteaUser +} from "./api"; +import { loadLocalContext, type LocalContext } from "./localContext"; export type StatusTone = "neutral" | "success" | "error"; +export type AppView = "urls" | "repos" | "workspaces"; + +export interface WindowPosition { + x: number; + y: number; +} interface AppState { + activeView: AppView; config: typeof giteaConfig; connectedUser: GiteaUser | null; + repos: GiteaRepoSummary[]; + localContext: LocalContext | null; + windowPosition: WindowPosition; + isLoadingData: boolean; isTestingConnection: boolean; statusMessage: string; statusTone: StatusTone; + setActiveView: (view: AppView) => void; + setWindowPosition: (position: WindowPosition) => void; + loadData: () => Promise; testConnection: () => Promise; } export const appStore = createStore()((set) => ({ + activeView: "repos", config: giteaConfig, connectedUser: null, + repos: [], + localContext: null, + windowPosition: { x: 10, y: 10 }, + isLoadingData: false, isTestingConnection: false, statusMessage: "Ready.", statusTone: "neutral", + setActiveView: (view) => set({ activeView: view }), + setWindowPosition: (position) => set({ windowPosition: position }), + loadData: async () => { + set({ + isLoadingData: true, + statusMessage: "Loading workspace and Gitea context...", + statusTone: "neutral" + }); + + try { + const [localContext, repos] = await Promise.all([ + loadLocalContext(), + getReposOrderedByLastCommit() + ]); + + set({ + localContext, + repos, + statusMessage: `Loaded ${repos.length} repos and ${localContext.workspaces.length} workspaces.`, + statusTone: "success" + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Could not load app data."; + set({ + statusMessage: message, + statusTone: "error" + }); + } finally { + set({ isLoadingData: false }); + } + }, testConnection: async () => { set({ connectedUser: null, diff --git a/src/styles.css b/src/styles.css index d732d4a..055b6fc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,6 +1,6 @@ :root { color: #1f2933; - background: #f5f7fa; + background: #e7edf3; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -13,37 +13,75 @@ } body { - min-width: 360px; + width: 720px; + min-width: 720px; + height: 620px; margin: 0; - background: #f5f7fa; + overflow: hidden; + background: + linear-gradient(135deg, rgb(255 255 255 / 72%), rgb(218 228 238 / 72%)), + #e7edf3; } -.app-shell { +button, +a { + -webkit-tap-highlight-color: transparent; +} + +.desktop-canvas { + position: relative; + width: 100vw; + height: 100vh; +} + +.app-window { + position: absolute; display: grid; - gap: 14px; - padding: 16px; + grid-template-rows: auto auto 1fr auto; + width: min(700px, calc(100vw - 20px)); + height: min(600px, calc(100vh - 20px)); + overflow: hidden; + border: 1px solid #bcccdc; + border-radius: 8px; + background: #f8fafc; + box-shadow: 0 18px 48px rgb(16 42 67 / 18%); } -.masthead { +.window-titlebar { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 14px; + gap: 16px; + padding: 12px 14px; + border-bottom: 1px solid #d9e2ec; + background: #ffffff; + cursor: grab; + user-select: none; + touch-action: none; +} + +.window-titlebar:active { + cursor: grabbing; } .eyebrow { - margin: 0 0 4px; + margin: 0 0 2px; color: #52616f; - font-size: 12px; + font-size: 11px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; } -h1 { +h1, +h2, +p { margin: 0; +} + +h1 { color: #102a43; - font-size: 22px; + font-size: 20px; line-height: 1.15; } @@ -70,64 +108,244 @@ h1 { color: #8d6e00; } -.panel { +.badge.quiet { + border-color: #d9e2ec; + background: #f5f7fa; + color: #52616f; +} + +.toolbar { display: grid; - gap: 14px; - border: 1px solid #d9e2ec; - border-radius: 8px; - padding: 14px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + border-bottom: 1px solid #d9e2ec; + background: #d9e2ec; +} + +.tool-button, +.icon-button, +.visit-button { + border: 0; + font: inherit; +} + +.tool-button { + display: grid; + justify-items: center; + gap: 4px; + min-width: 0; + min-height: 58px; + padding: 8px 6px; background: #ffffff; - box-shadow: 0 8px 24px rgb(16 42 67 / 8%); + color: #334e68; + cursor: pointer; } -.config-list { +.tool-button svg { + width: 19px; + height: 19px; + stroke-width: 2.25; +} + +.tool-button span { + max-width: 100%; + overflow: hidden; + font-size: 11px; + font-weight: 700; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-button[data-active="true"] { + background: #e6f6ff; + color: #075985; +} + +.tool-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.content-panel { display: grid; - gap: 10px; - margin: 0; + grid-template-rows: auto 1fr; + min-height: 0; } -.config-list div { - display: grid; - gap: 3px; +.view-header { + padding: 14px 16px 10px; + border-bottom: 1px solid #edf2f7; + background: #f8fafc; } -dt { +.view-header h2 { + color: #102a43; + font-size: 17px; + line-height: 1.2; +} + +.view-header p { + margin-top: 3px; color: #52616f; font-size: 12px; - font-weight: 700; + line-height: 1.4; } -dd { - margin: 0; - overflow-wrap: anywhere; +.view-content { + display: grid; + align-content: start; + gap: 10px; + min-height: 0; + overflow-y: auto; + padding: 12px; +} + +.item-card { + display: grid; + gap: 9px; + border: 1px solid #d9e2ec; + border-radius: 8px; + padding: 12px; + background: #ffffff; +} + +.item-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.item-title { color: #102a43; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; - font-size: 13px; + font-size: 14px; + font-weight: 800; + line-height: 1.25; + text-decoration: none; } -button { - width: 100%; - border: 0; +a.item-title:hover, +.repo-link:hover, +.url-text:hover { + text-decoration: underline; +} + +.item-subtitle { + margin-top: 3px; + color: #52616f; + font-size: 12px; + line-height: 1.4; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + color: #52616f; + font-size: 11px; +} + +.meta-grid strong { + color: #243b53; + font-weight: 800; +} + +.commit-message { + color: #243b53; + font-size: 12px; + line-height: 1.4; +} + +.linked-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + color: #52616f; + font-size: 12px; +} + +.link-chip { + display: inline-flex; + align-items: center; + border: 1px solid #9fb3c8; + border-radius: 999px; + padding: 2px 7px; + background: #f0f4f8; + color: #334e68; + font-size: 11px; + font-weight: 800; +} + +.path-text { + overflow-wrap: anywhere; + color: #334e68; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 11px; + line-height: 1.35; +} + +.repo-link-list { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.repo-link, +.url-text { + overflow-wrap: anywhere; + color: #0b6bcb; + font-size: 12px; + font-weight: 700; + text-decoration: none; +} + +.visit-button { + flex: 0 0 auto; border-radius: 6px; - padding: 10px 12px; + padding: 7px 10px; background: #0b6bcb; color: #ffffff; cursor: pointer; - font: inherit; - font-weight: 700; + font-size: 12px; + font-weight: 800; } -button:disabled { - cursor: wait; - opacity: 0.7; +.empty-state { + border: 1px dashed #bcccdc; + border-radius: 8px; + padding: 18px; + color: #52616f; + background: #ffffff; + font-size: 13px; + text-align: center; +} + +.muted { + color: #829ab1; + font-size: 12px; +} + +.statusbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 42px; + border-top: 1px solid #d9e2ec; + padding: 8px 10px 8px 14px; + background: #ffffff; } #status { - min-height: 20px; - margin: 0; + min-width: 0; + overflow: hidden; color: #52616f; - font-size: 13px; + font-size: 12px; line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; } #status[data-tone="success"] { @@ -137,3 +355,36 @@ button:disabled { #status[data-tone="error"] { color: #b42318; } + +.icon-button { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 30px; + height: 30px; + border-radius: 6px; + background: #eef4f9; + color: #334e68; + cursor: pointer; +} + +.icon-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.icon-button svg { + width: 16px; + height: 16px; +} + +@media (max-width: 520px) { + body { + width: 420px; + min-width: 420px; + } + + .meta-grid { + grid-template-columns: 1fr; + } +}