Initial commit: MeOS Dashboard v0.1 — Next.js 14, Forgejo API layer, Tailwind CSS
This commit is contained in:
commit
1ac10cb630
35 changed files with 9357 additions and 0 deletions
14
.env.local.example
Normal file
14
.env.local.example
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# MeOS Dashboard Environment Variables
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
|
||||
# Forgejo API endpoint (local network)
|
||||
FORGEJO_API_URL=http://192.168.168.221:3000/api/v1
|
||||
|
||||
# Forgejo personal access token (read from ~/meos/forgejo-token.txt)
|
||||
FORGEJO_TOKEN=your-token-here
|
||||
|
||||
# OpenClaw Gateway API URL (optional, for agent status)
|
||||
OPENCLAW_API_URL=http://localhost:7477
|
||||
|
||||
# Vault path (filesystem)
|
||||
VAULT_PATH=/home/sscoble/vault
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
*.log
|
||||
.next/
|
||||
out/
|
||||
130
README.md
Normal file
130
README.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# MeOS Dashboard
|
||||
|
||||
A web UI for the MeOS platform — wraps Forgejo API, vault browsing, and agent monitoring into one view.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → nginx (n97) → MeOS Dashboard (Next.js on port 3001)
|
||||
├── Forgejo API (repos, PRs, CI status)
|
||||
├── Filesystem API (vault browsing via server-side reader)
|
||||
└── OpenClaw Gateway API (agent status, memory)
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- **Framework:** Next.js 14 (App Router)
|
||||
- **Styling:** Tailwind CSS (dark mode default, light mode toggle)
|
||||
- **Deployment:** Node.js systemd service, reverse-proxied by nginx
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
cd ~/vault/meos/dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` and add your Forgejo API token:
|
||||
|
||||
```
|
||||
FORGEJO_API_URL=http://192.168.168.221:3000/api/v1
|
||||
FORGEJO_TOKEN=your-token-here
|
||||
VAULT_PATH=/home/sscoble/vault
|
||||
OPENCLAW_API_URL=http://localhost:7477
|
||||
```
|
||||
|
||||
### 3. Run in development mode
|
||||
|
||||
```bash
|
||||
cd ~/vault/meos/dashboard
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or use the startup script:
|
||||
|
||||
```bash
|
||||
~/vault/meos/start-dashboard.sh
|
||||
```
|
||||
|
||||
Open **http://n97:3001** (or http://192.168.168.97:3001).
|
||||
|
||||
### 4. Build for production
|
||||
|
||||
```bash
|
||||
cd ~/vault/meos/dashboard
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
### 5. Install as a systemd service (production)
|
||||
|
||||
```bash
|
||||
# First build the production bundle
|
||||
cd ~/vault/meos/dashboard
|
||||
npm run build
|
||||
|
||||
# Then install the service
|
||||
sudo cp ~/vault/meos/meos-dashboard.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable meos-dashboard
|
||||
sudo systemctl start meos-dashboard
|
||||
```
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
sudo systemctl status meos-dashboard
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `/` | Dashboard overview — repos, recent zettels, agent status, quick actions |
|
||||
| `/vault` | Vault browser — file tree, zettel viewer, search, graph view |
|
||||
| `/vault/[slug]` | Direct zettel link |
|
||||
| `/presentations` | Presentation repos and CI status |
|
||||
| `/agents` | Department health, gateway status, memory info |
|
||||
| `/pipeline` | CI/CD runs and recent commits |
|
||||
|
||||
## API Routes (internal, used by the frontend)
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `GET /api/forgejo/repos` | List all repos |
|
||||
| `GET /api/forgejo/commits` | Recent commits across repos |
|
||||
| `GET /api/forgejo/ci-runs` | CI/CD workflow runs |
|
||||
| `GET /api/vault/list` | List all vault .md files |
|
||||
| `GET /api/vault/read?path=` | Read a zettel |
|
||||
| `GET /api/vault/search?q=` | Search zettel content |
|
||||
| `GET /api/vault/recent` | Last 5 modified zettels |
|
||||
| `GET /api/vault/graph` | [[wiki-link]] graph JSON |
|
||||
| `GET /api/agent/status` | Gateway health check |
|
||||
| `GET /api/agent/departments` | Department statuses |
|
||||
| `GET /api/presentations/list` | Presentation repos |
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **No authentication for MVP** — runs on local network only
|
||||
- **No Docker** — n97 is a low-power NucBoxG5, keep it lean
|
||||
- **Forgejo API** is local at `192.168.168.221:3000` — proxied through Next.js API routes
|
||||
- **Vault access** is direct filesystem reads from `~/vault/` — no database needed
|
||||
- **Port 3001** to avoid conflict with Forgejo on port 3000
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- [ ] Full D3.js graph visualization for vault wiki-links
|
||||
- [ ] nginx reverse proxy with HTTPS (Let's Encrypt)
|
||||
- [ ] Authentication (basic auth or OAuth proxy)
|
||||
- [ ] Forgejo webhook integration (auto-refresh on push)
|
||||
- [ ] Real-time agent status via WebSocket
|
||||
- [ ] Zettel editing and creation from the UI
|
||||
- [ ] OpenClaw Gateway integration for session management
|
||||
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
next.config.js
Normal file
10
next.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// MeOS Dashboard listens on port 3001 (Forgejo is on 3000)
|
||||
// No special server config needed — we use API routes as a proxy layer.
|
||||
experimental: {
|
||||
serverActions: false,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
7245
package-lock.json
generated
Normal file
7245
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
package.json
Normal file
28
package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "meos-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "MeOS Dashboard — Forgejo + vault + agent monitoring UI",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"date-fns": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
100
src/app/agents/page.js
Normal file
100
src/app/agents/page.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AgentStatus from "@/components/AgentStatus";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [gatewayStatus, setGatewayStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAgentStatus() {
|
||||
try {
|
||||
const [deptRes, gatewayRes] = await Promise.all([
|
||||
fetch("/api/agent/departments"),
|
||||
fetch("/api/agent/status"),
|
||||
]);
|
||||
const deptData = deptRes.ok ? await deptRes.json() : DEPARTMENTS.map(d => ({
|
||||
name: d.name,
|
||||
role: d.role,
|
||||
status: "unreachable",
|
||||
lastSession: null,
|
||||
memorySize: null,
|
||||
}));
|
||||
const gatewayData = gatewayRes.ok ? await gatewayRes.json() : null;
|
||||
setDepartments(deptData);
|
||||
setGatewayStatus(gatewayData);
|
||||
} catch (err) {
|
||||
console.error("Agent status load error:", err);
|
||||
// Fallback to static data
|
||||
setDepartments(
|
||||
DEPARTMENTS.map((d) => ({
|
||||
name: d.name,
|
||||
role: d.role,
|
||||
status: "unreachable",
|
||||
lastSession: null,
|
||||
memorySize: null,
|
||||
}))
|
||||
);
|
||||
setGatewayStatus({ online: false, error: err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadAgentStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white mb-2">Agent System</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">Department health, memory, and gateway status</p>
|
||||
|
||||
{/* Gateway status */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-sm font-medium text-gray-300 mb-3">Gateway</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
gatewayStatus?.online ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-400">
|
||||
{gatewayStatus?.online ? "Online" : "Offline"}
|
||||
</span>
|
||||
{gatewayStatus?.version && (
|
||||
<span className="text-xs text-gray-600">v{gatewayStatus.version}</span>
|
||||
)}
|
||||
{gatewayStatus?.error && (
|
||||
<span className="text-xs text-red-400">{gatewayStatus.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Departments */}
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||
Departments
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse text-gray-500 text-sm">Loading departments...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{departments.map((dept, i) => (
|
||||
<AgentStatus key={i} agent={dept} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="mt-8 text-xs text-gray-600 border-t border-gray-800 pt-4">
|
||||
<p>
|
||||
Agent status checks the OpenClaw Gateway API at{" "}
|
||||
<code className="bg-gray-800 px-1 rounded">localhost:7477</code>.
|
||||
If unreachable, departments show as idle with no session data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/app/api/agent/departments/route.js
Normal file
69
src/app/api/agent/departments/route.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// GET /api/agent/departments — list departments with status
|
||||
import { execSync } from "child_process";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const departments = DEPARTMENTS.map((d) => {
|
||||
let status = "idle";
|
||||
let lastSession = null;
|
||||
let memorySize = null;
|
||||
|
||||
try {
|
||||
// Check for memory files per department
|
||||
const name = d.name.replace("@", "");
|
||||
const memPath = `/home/sscoble/.openclaw/workspace/memory/${name}-YYYY-MM-DD.md`;
|
||||
// Simple check: does a recent memory file exist?
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0];
|
||||
|
||||
// Check if the agent's process is running
|
||||
const procCheck = execSync(
|
||||
`pgrep -f 'openclaw.*${name}' || true`,
|
||||
{ encoding: "utf-8", timeout: 2000 }
|
||||
);
|
||||
if (procCheck.trim().length > 0) {
|
||||
status = "running";
|
||||
}
|
||||
|
||||
// Check memory file size
|
||||
try {
|
||||
const stat = execSync(
|
||||
`stat --format='%Y %s' /home/sscoble/.openclaw/workspace/memory/${name}-*.md 2>/dev/null || true`,
|
||||
{ encoding: "utf-8", timeout: 2000 }
|
||||
);
|
||||
if (stat.trim()) {
|
||||
const parts = stat.trim().split(" ");
|
||||
const timestamp = parseInt(parts[0]) * 1000;
|
||||
const sizeBytes = parseInt(parts[1]);
|
||||
lastSession = new Date(timestamp).toISOString();
|
||||
memorySize = formatBytes(sizeBytes);
|
||||
}
|
||||
} catch {
|
||||
// no memory file
|
||||
}
|
||||
} catch {
|
||||
// process check failed
|
||||
}
|
||||
|
||||
return {
|
||||
name: d.name,
|
||||
role: d.role,
|
||||
status,
|
||||
lastSession,
|
||||
memorySize,
|
||||
};
|
||||
});
|
||||
|
||||
return Response.json(departments);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return null;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
47
src/app/api/agent/status/route.js
Normal file
47
src/app/api/agent/status/route.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// GET /api/agent/status — check if OpenClaw gateway is running
|
||||
import { execSync } from "child_process";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Try to check OpenClaw gateway health
|
||||
let online = false;
|
||||
let version = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
// Check if the gateway process is running
|
||||
const result = execSync("pgrep -f 'openclaw gateway' || true", {
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
});
|
||||
online = result.trim().length > 0;
|
||||
} catch {
|
||||
// Try alternative check
|
||||
try {
|
||||
const res = await fetch("http://localhost:7477/health", {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
online = res.ok;
|
||||
if (online) {
|
||||
const data = await res.json();
|
||||
version = data.version;
|
||||
}
|
||||
} catch {
|
||||
online = false;
|
||||
error = "Gateway unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
online,
|
||||
version,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ online: false, error: err.message, timestamp: new Date().toISOString() },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/app/api/forgejo/ci-runs/route.js
Normal file
24
src/app/api/forgejo/ci-runs/route.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// GET /api/forgejo/ci-runs — list CI runs across repos
|
||||
import { getRepos, getCIRuns } from "@/lib/forgejo-api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const repos = await getRepos();
|
||||
const allRuns = [];
|
||||
for (const repo of repos.slice(0, 5)) {
|
||||
const runs = await getCIRuns(repo.owner, repo.name, 5);
|
||||
if (runs.workflow_runs) {
|
||||
runs.workflow_runs.forEach((run) => {
|
||||
allRuns.push({ ...run, repo: repo.name });
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort by creation date, newest first
|
||||
allRuns.sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
return Response.json(allRuns.slice(0, 20));
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
src/app/api/forgejo/commits/route.js
Normal file
11
src/app/api/forgejo/commits/route.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// GET /api/forgejo/commits — recent commits across repos
|
||||
import { getRecentCommits } from "@/lib/forgejo-api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const commits = await getRecentCommits(20);
|
||||
return Response.json(commits);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
src/app/api/forgejo/repos/route.js
Normal file
11
src/app/api/forgejo/repos/route.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// GET /api/forgejo/repos — list all repos
|
||||
import { getRepos } from "@/lib/forgejo-api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const repos = await getRepos();
|
||||
return Response.json(repos);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/presentations/list/route.js
Normal file
29
src/app/api/presentations/list/route.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// GET /api/presentations/list — list presentation-related repos and files
|
||||
import { getRepos } from "@/lib/forgejo-api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const repos = await getRepos();
|
||||
// Filter repos that look like presentations (contain "presentation" or "present" in name)
|
||||
const presentationRepos = repos.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes("presentation") ||
|
||||
r.name.toLowerCase().includes("present") ||
|
||||
r.name.toLowerCase().includes("talk") ||
|
||||
r.description?.toLowerCase().includes("presentation")
|
||||
);
|
||||
|
||||
// Enrich with file listing (just return the repo info for now)
|
||||
const result = presentationRepos.map((r) => ({
|
||||
name: r.name,
|
||||
full_name: r.full_name,
|
||||
description: r.description,
|
||||
updated_at: r.updated_at,
|
||||
files: [], // Could fetch file list via Forgejo API
|
||||
}));
|
||||
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
src/app/api/vault/graph/route.js
Normal file
11
src/app/api/vault/graph/route.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// GET /api/vault/graph — zettel link graph
|
||||
import { getGraph } from "@/lib/vault-reader";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const graph = getGraph();
|
||||
return Response.json(graph);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
src/app/api/vault/list/route.js
Normal file
11
src/app/api/vault/list/route.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// GET /api/vault/list — list all zettels
|
||||
import { listZettels } from "@/lib/vault-reader";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const zettels = listZettels();
|
||||
return Response.json(zettels);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/vault/read/route.js
Normal file
16
src/app/api/vault/read/route.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// GET /api/vault/read?path=... — read a zettel
|
||||
import { getZettel } from "@/lib/vault-reader";
|
||||
|
||||
export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const zettelPath = searchParams.get("path");
|
||||
if (!zettelPath) {
|
||||
return Response.json({ error: "Missing path parameter" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const zettel = getZettel(zettelPath);
|
||||
return Response.json(zettel);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
src/app/api/vault/recent/route.js
Normal file
11
src/app/api/vault/recent/route.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// GET /api/vault/recent — recent zettels
|
||||
import { getRecentZettels } from "@/lib/vault-reader";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const zettels = getRecentZettels(5);
|
||||
return Response.json(zettels);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/vault/search/route.js
Normal file
16
src/app/api/vault/search/route.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// GET /api/vault/search?q=... — search zettels
|
||||
import { searchZettels } from "@/lib/vault-reader";
|
||||
|
||||
export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q");
|
||||
if (!query) {
|
||||
return Response.json({ error: "Missing query parameter" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const results = searchZettels(query);
|
||||
return Response.json(results);
|
||||
} catch (error) {
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
40
src/app/layout.js
Normal file
40
src/app/layout.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import TopBar from "@/components/TopBar";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<title>MeOS Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col transition-all duration-200 ${
|
||||
sidebarCollapsed ? "ml-16" : "ml-60"
|
||||
}`}
|
||||
>
|
||||
<TopBar />
|
||||
<main className="flex-1 p-4 lg:p-6 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
144
src/app/page.js
Normal file
144
src/app/page.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import RepoCard from "@/components/RepoCard";
|
||||
import AgentStatus from "@/components/AgentStatus";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [repos, setRepos] = useState([]);
|
||||
const [recentZettels, setRecentZettels] = useState([]);
|
||||
const [agentStatuses, setAgentStatuses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const [reposRes, zettelsRes, agentsRes] = await Promise.all([
|
||||
fetch("/api/forgejo/repos"),
|
||||
fetch("/api/vault/recent"),
|
||||
fetch("/api/agent/departments"),
|
||||
]);
|
||||
const reposData = reposRes.ok ? await reposRes.json() : [];
|
||||
const zettelsData = zettelsRes.ok ? await zettelsRes.json() : [];
|
||||
const agentsData = agentsRes.ok ? await agentsRes.json() : [];
|
||||
|
||||
setRepos(reposData.slice(0, 6));
|
||||
setRecentZettels(zettelsData);
|
||||
setAgentStatuses(agentsData);
|
||||
} catch (err) {
|
||||
console.error("Dashboard load error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-pulse text-gray-500 text-sm">Loading dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white mb-6">Dashboard</h1>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||
<QuickAction icon="📓" label="New Note" href="/vault" />
|
||||
<QuickAction icon="🎤" label="New Presentation" href="/presentations" />
|
||||
<QuickAction icon="📤" label="Push to Forgejo" href="http://192.168.168.221:3000" external />
|
||||
<QuickAction icon="🤖" label="Agent Status" href="/agents" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Repos column */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||
Repositories
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{repos.length > 0 ? (
|
||||
repos.map((repo) => <RepoCard key={repo.id} repo={repo} />)
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">No repos loaded. Is Forgejo running?</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent vault + agents column */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||
Recent Vault Activity
|
||||
</h2>
|
||||
<div className="space-y-2 mb-6">
|
||||
{recentZettels.length > 0 ? (
|
||||
recentZettels.map((z, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={`/vault/${encodeURIComponent(z.path)}`}
|
||||
className="card block hover:border-meos-800/50 transition-colors"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-200 truncate">{z.name}</p>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{z.contentPreview}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{new Date(z.modifiedAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">No vault files found.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||
Agent Status
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{agentStatuses.length > 0 ? (
|
||||
agentStatuses.map((a, i) => <AgentStatus key={i} agent={a} />)
|
||||
) : (
|
||||
DEPARTMENTS.map((d, i) => (
|
||||
<AgentStatus
|
||||
key={i}
|
||||
agent={{ name: d.name, role: d.role, status: "idle" }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({ icon, label, href, external }) {
|
||||
const cls = "card flex items-center space-x-3 hover:border-meos-800/50 transition-colors cursor-pointer";
|
||||
const content = (
|
||||
<>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<span className="text-sm font-medium text-gray-300">{label}</span>
|
||||
</>
|
||||
);
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={cls}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} className={cls}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
133
src/app/pipeline/page.js
Normal file
133
src/app/pipeline/page.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function PipelinePage() {
|
||||
const [commits, setCommits] = useState([]);
|
||||
const [ciRuns, setCiRuns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPipeline() {
|
||||
try {
|
||||
const [commitsRes, ciRes] = await Promise.all([
|
||||
fetch("/api/forgejo/commits"),
|
||||
fetch("/api/forgejo/ci-runs"),
|
||||
]);
|
||||
const commitsData = commitsRes.ok ? await commitsRes.json() : [];
|
||||
const ciData = ciRes.ok ? await ciRes.json() : [];
|
||||
setCommits(commitsData);
|
||||
setCiRuns(ciData);
|
||||
} catch (err) {
|
||||
console.error("Pipeline load error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadPipeline();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white mb-2">Pipeline</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">CI/CD runs and recent commits</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse text-gray-500 text-sm">Loading pipeline data...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* CI/CD runs */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||
CI/CD Runs
|
||||
</h2>
|
||||
{ciRuns.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{ciRuns.map((run, i) => (
|
||||
<div key={i} className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-200 truncate">
|
||||
{run.name || run.workflow_name || "Workflow"}
|
||||
</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full flex-shrink-0 ${
|
||||
run.conclusion === "success"
|
||||
? "bg-green-900/50 text-green-400"
|
||||
: run.conclusion === "failure"
|
||||
? "bg-red-900/50 text-red-400"
|
||||
: run.status === "running"
|
||||
? "bg-blue-900/50 text-blue-400"
|
||||
: "bg-yellow-900/50 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{run.conclusion || run.status || "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
{run.repo && (
|
||||
<p className="text-xs text-gray-600 mt-1">{run.repo}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
{run.created_at
|
||||
? new Date(run.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card flex items-center justify-center h-32">
|
||||
<p className="text-sm text-gray-500">
|
||||
No CI/CD runs found. Configure Forgejo Actions to see runs here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent commits */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||
Recent Commits
|
||||
</h2>
|
||||
{commits.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{commits.map((c, i) => (
|
||||
<div key={i} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-gray-200 truncate">{c.message}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<span className="text-meos-500">{c.repo}</span> ·{" "}
|
||||
<span className="font-mono">{c.sha}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xs text-gray-600 ml-2 flex-shrink-0">
|
||||
{new Date(c.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{c.author}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card flex items-center justify-center h-32">
|
||||
<p className="text-sm text-gray-500">
|
||||
No commits found. Push to a Forgejo repo to see commits here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/app/presentations/page.js
Normal file
85
src/app/presentations/page.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function PresentationsPage() {
|
||||
const [reposWithPresentations, setReposWithPresentations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPresentations() {
|
||||
try {
|
||||
const res = await fetch("/api/presentations/list");
|
||||
const data = res.ok ? await res.json() : [];
|
||||
setReposWithPresentations(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load presentations:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadPresentations();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white mb-2">Presentations</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Presentation drafts and CI/CD status
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse text-gray-500 text-sm">Loading presentations...</div>
|
||||
) : reposWithPresentations.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{reposWithPresentations.map((repo, i) => (
|
||||
<div key={i} className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-meos-400">{repo.name}</h3>
|
||||
{repo.ciStatus && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
repo.ciStatus === "success"
|
||||
? "bg-green-900/50 text-green-400"
|
||||
: repo.ciStatus === "failure"
|
||||
? "bg-red-900/50 text-red-400"
|
||||
: "bg-yellow-900/50 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{repo.ciStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{repo.description}</p>
|
||||
)}
|
||||
{repo.files && repo.files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{repo.files.map((f, j) => (
|
||||
<a
|
||||
key={j}
|
||||
href={`http://192.168.168.221:3000/${repo.full_name}/src/branch/main/${f.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-xs text-gray-400 hover:text-meos-400 transition-colors"
|
||||
>
|
||||
<span className="mr-2">🎤</span>
|
||||
{f.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card flex items-center justify-center h-32">
|
||||
<p className="text-sm text-gray-500">
|
||||
No presentation repositories found. Create one with{" "}
|
||||
<code className="text-meos-400 bg-gray-800 px-1 rounded">meos-scaffold</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/app/vault/[slug]/page.js
Normal file
98
src/app/vault/[slug]/page.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function ZettelPage({ params }) {
|
||||
const [zettel, setZettel] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadZettel() {
|
||||
try {
|
||||
// params.slug is the path from the URL — need to decode
|
||||
const slug = decodeURIComponent(params.slug);
|
||||
const res = await fetch(`/api/vault/read?path=${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
const data = await res.json();
|
||||
setZettel(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadZettel();
|
||||
}, [params.slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-pulse text-gray-500 text-sm">Loading zettel...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !zettel) {
|
||||
return (
|
||||
<div className="card flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-sm mb-2">Zettel not found</p>
|
||||
<a href="/vault" className="text-meos-400 text-sm hover:underline">
|
||||
← Back to vault
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fm = zettel.frontmatter || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a href="/vault" className="text-xs text-gray-500 hover:text-meos-400 mb-4 inline-block">
|
||||
← Back to vault browser
|
||||
</a>
|
||||
|
||||
{/* Frontmatter metadata */}
|
||||
{Object.keys(fm).length > 0 && (
|
||||
<div className="card mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-gray-500 mr-2">Metadata:</span>
|
||||
{fm.id && <span className="tag">ID: {fm.id}</span>}
|
||||
{fm.category && <span className="tag">📂 {fm.category}</span>}
|
||||
{fm.title && <span className="tag">📌 {fm.title}</span>}
|
||||
{fm.created && (
|
||||
<span className="tag">
|
||||
📅 {new Date(fm.created).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{Array.isArray(fm.tags) &&
|
||||
fm.tags.map((t, i) => (
|
||||
<span key={i} className="tag">
|
||||
# {t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="card">
|
||||
<div className="prose-zettel whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{zettel.content}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-600 border-t border-gray-800 pt-3">
|
||||
{zettel.path} ·{" "}
|
||||
{new Date(zettel.modifiedAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/app/vault/page.js
Normal file
308
src/app/vault/page.js
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export default function VaultPage() {
|
||||
const [zettels, setZettels] = useState([]);
|
||||
const [tree, setTree] = useState([]);
|
||||
const [selectedZettel, setSelectedZettel] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showGraph, setShowGraph] = useState(false);
|
||||
const [graphData, setGraphData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchZettels();
|
||||
}, []);
|
||||
|
||||
async function fetchZettels() {
|
||||
try {
|
||||
const res = await fetch("/api/vault/list");
|
||||
const data = res.ok ? await res.json() : [];
|
||||
setZettels(data);
|
||||
setTree(buildTree(data));
|
||||
} catch (err) {
|
||||
console.error("Failed to load vault:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(zettelList) {
|
||||
const root = { name: "vault", children: [], files: [] };
|
||||
for (const z of zettelList) {
|
||||
const parts = z.path.split("/");
|
||||
let current = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
let child = current.children.find((c) => c.name === parts[i]);
|
||||
if (!child) {
|
||||
child = { name: parts[i], children: [], files: [] };
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
current.files.push(z);
|
||||
}
|
||||
return [root];
|
||||
}
|
||||
|
||||
async function openZettel(zettelPath) {
|
||||
try {
|
||||
const res = await fetch(`/api/vault/read?path=${encodeURIComponent(zettelPath)}`);
|
||||
const data = res.ok ? await res.json() : null;
|
||||
setSelectedZettel(data);
|
||||
setSearchResults(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to load zettel:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/vault/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
const data = res.ok ? await res.json() : [];
|
||||
setSearchResults(data);
|
||||
setSelectedZettel(null);
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
setShowGraph(!showGraph);
|
||||
if (!graphData) {
|
||||
try {
|
||||
const res = await fetch("/api/vault/graph");
|
||||
const data = res.ok ? await res.json() : { nodes: [], links: [] };
|
||||
setGraphData(data);
|
||||
} catch (err) {
|
||||
console.error("Graph load failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-semibold text-white">Vault</h1>
|
||||
<button
|
||||
onClick={loadGraph}
|
||||
className="text-xs text-gray-400 hover:text-meos-400 transition-colors px-3 py-1.5 border border-gray-700 rounded-lg"
|
||||
>
|
||||
{showGraph ? "📁 Tree View" : "🔗 Graph View"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<form onSubmit={handleSearch} className="mb-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search zettel content..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-meos-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse text-gray-500 text-sm">Loading vault...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* File tree sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="card max-h-[70vh] overflow-y-auto">
|
||||
{showGraph && graphData ? (
|
||||
<SimpleGraph graph={graphData} onSelectNode={openZettel} />
|
||||
) : (
|
||||
<FileTree
|
||||
nodes={tree[0]?.children || []}
|
||||
files={tree[0]?.files || []}
|
||||
onOpen={openZettel}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="lg:col-span-2">
|
||||
{searchResults ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Found {searchResults.length} results
|
||||
</p>
|
||||
{searchResults.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => openZettel(r.path)}
|
||||
className="card block w-full text-left hover:border-meos-800/50 transition-colors"
|
||||
>
|
||||
<p className="text-sm font-medium text-meos-400">{r.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono mt-1">{r.path}</p>
|
||||
<p className="text-xs text-gray-400 mt-2 leading-relaxed">
|
||||
{r.excerpt}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : selectedZettel ? (
|
||||
<ZettelViewer zettel={selectedZettel} />
|
||||
) : (
|
||||
<div className="card flex items-center justify-center h-64 text-gray-500 text-sm">
|
||||
<p>Select a zettel from the tree or search to view</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTree({ nodes, files, onOpen, depth }) {
|
||||
const [collapsed, setCollapsed] = useState({});
|
||||
|
||||
function toggle(name) {
|
||||
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{nodes.map((node) => {
|
||||
const isCollapsed = collapsed[node.name];
|
||||
return (
|
||||
<li key={node.name}>
|
||||
<button
|
||||
onClick={() => toggle(node.name)}
|
||||
className="flex items-center w-full text-left px-2 py-1 text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded transition-colors"
|
||||
>
|
||||
<span className="mr-1.5 text-gray-600">
|
||||
{isCollapsed ? "▶" : "▼"}
|
||||
</span>
|
||||
<span className="mr-1">📁</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4">
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
files={node.files}
|
||||
onOpen={onOpen}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<li key={file.path}>
|
||||
<button
|
||||
onClick={() => onOpen(file.path)}
|
||||
className="flex items-center w-full text-left px-2 py-1 text-xs text-gray-400 hover:text-meos-400 hover:bg-gray-800 rounded transition-colors"
|
||||
>
|
||||
<span className="mr-1.5">📄</span>
|
||||
<span className="truncate">{file.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ZettelViewer({ zettel }) {
|
||||
const fm = zettel.frontmatter || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Frontmatter metadata */}
|
||||
{Object.keys(fm).length > 0 && (
|
||||
<div className="card mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fm.id && <span className="tag">ID: {fm.id}</span>}
|
||||
{fm.category && <span className="tag">📂 {fm.category}</span>}
|
||||
{fm.created && (
|
||||
<span className="tag">
|
||||
📅 {new Date(fm.created).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{Array.isArray(fm.tags) &&
|
||||
fm.tags.map((t, i) => (
|
||||
<span key={i} className="tag">
|
||||
# {t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="card">
|
||||
<div className="prose-zettel whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{zettel.content}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-600 border-t border-gray-800 pt-3">
|
||||
{zettel.path} ·{" "}
|
||||
{new Date(zettel.modifiedAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleGraph({ graph, onSelectNode }) {
|
||||
if (!graph.nodes || graph.nodes.length === 0) {
|
||||
return <p className="text-xs text-gray-500">No graph data available</p>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{graph.nodes.length} nodes, {graph.links.length} links
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{graph.nodes.slice(0, 50).map((node, i) => {
|
||||
// Count links to/from this node
|
||||
const linkCount = graph.links.filter(
|
||||
(l) => l.source === i || l.target === i
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelectNode(node.id)}
|
||||
className="flex items-center w-full text-left px-2 py-1 text-xs text-gray-400 hover:text-meos-400 hover:bg-gray-800 rounded transition-colors"
|
||||
>
|
||||
<span className="mr-1.5">{linkCount > 0 ? "🔗" : "📄"}</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="ml-auto text-gray-600 text-2xs">{linkCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.length > 50 && (
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
+{graph.nodes.length - 50} more nodes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-3">
|
||||
(Full D3 graph coming soon)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/AgentStatus.js
Normal file
33
src/components/AgentStatus.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
export default function AgentStatus({ agent }) {
|
||||
const statusColor =
|
||||
agent.status === "running"
|
||||
? "bg-green-500"
|
||||
: agent.status === "idle"
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-500";
|
||||
|
||||
return (
|
||||
<div className="card flex items-center space-x-3">
|
||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-200">{agent.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{agent.role}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-gray-500 flex-shrink-0">
|
||||
{agent.lastSession && (
|
||||
<p>
|
||||
{new Date(agent.lastSession).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{agent.memorySize && <p className="mt-0.5">{agent.memorySize}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/RepoCard.js
Normal file
44
src/components/RepoCard.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
export default function RepoCard({ repo }) {
|
||||
const lastPush = repo.pushed_at
|
||||
? new Date(repo.pushed_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "unknown";
|
||||
|
||||
return (
|
||||
<div className="card hover:border-meos-800/50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-meos-400 hover:text-meos-300 truncate block"
|
||||
>
|
||||
{repo.name}
|
||||
</a>
|
||||
{repo.description && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{repo.language && (
|
||||
<span className="tag ml-2 flex-shrink-0">{repo.language}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center mt-3 text-xs text-gray-500 space-x-3">
|
||||
<span>🔄 {lastPush}</span>
|
||||
{repo.stars > 0 && <span>⭐ {repo.stars}</span>}
|
||||
{repo.open_issues_count > 0 && (
|
||||
<span>❗ {repo.open_issues_count}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/Sidebar.js
Normal file
85
src/components/Sidebar.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { NAV_ITEMS } from "@/lib/constants";
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }) {
|
||||
const [activePath, setActivePath] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setActivePath(window.location.pathname);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-full bg-gray-900 border-r border-gray-800 transition-all duration-200 z-30 flex flex-col ${
|
||||
collapsed ? "w-16" : "w-60"
|
||||
}`}
|
||||
>
|
||||
{/* Logo / toggle */}
|
||||
<div className="flex items-center h-14 px-3 border-b border-gray-800">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-gray-400 hover:text-white transition-colors p-1"
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{collapsed ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<span className="ml-2 text-sm font-semibold text-meos-400 tracking-wide uppercase">
|
||||
MeOS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 py-2 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activePath === item.href;
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center px-3 py-2.5 mx-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-meos-900/50 text-meos-300 border border-meos-800/50"
|
||||
: "text-gray-400 hover:text-gray-200 hover:bg-gray-800"
|
||||
}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<span className="ml-3 truncate">{item.label}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom links */}
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-3 border-t border-gray-800">
|
||||
<a
|
||||
href="http://192.168.168.221:3000"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-xs text-gray-500 hover:text-meos-400 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Forgejo →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
107
src/components/TopBar.js
Normal file
107
src/components/TopBar.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function TopBar({ onSearch }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [agentOnline, setAgentOnline] = useState(null);
|
||||
const [theme, setTheme] = useState("dark");
|
||||
|
||||
useEffect(() => {
|
||||
// Check agent status
|
||||
checkAgentStatus();
|
||||
// Apply saved theme
|
||||
const saved = localStorage.getItem("theme") || "dark";
|
||||
setTheme(saved);
|
||||
document.documentElement.classList.toggle("dark", saved === "dark");
|
||||
document.documentElement.classList.toggle("light", saved === "light");
|
||||
}, []);
|
||||
|
||||
async function checkAgentStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/agent/status");
|
||||
const data = await res.json();
|
||||
setAgentOnline(data.online);
|
||||
} catch {
|
||||
setAgentOnline(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
localStorage.setItem("theme", next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
document.documentElement.classList.toggle("light", next === "light");
|
||||
}
|
||||
|
||||
function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
if (onSearch) onSearch(query);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 h-14 border-b border-gray-800 bg-gray-950/80 backdrop-blur-sm flex items-center justify-between px-4 lg:px-6">
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search vault, repos, agents..."
|
||||
className="w-full pl-10 pr-4 py-1.5 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-meos-500 focus:border-meos-500"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Right side controls */}
|
||||
<div className="flex items-center space-x-4 ml-4">
|
||||
{/* Agent status indicator */}
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
agentOnline === null
|
||||
? "bg-yellow-500 animate-pulse"
|
||||
: agentOnline
|
||||
? "bg-green-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{agentOnline === null
|
||||
? "checking..."
|
||||
: agentOnline
|
||||
? "online"
|
||||
: "offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
52
src/lib/constants.js
Normal file
52
src/lib/constants.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// MeOS Dashboard constants and configuration
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
icon: "📊",
|
||||
description: "Overview of repos, vault, and agents",
|
||||
},
|
||||
{
|
||||
label: "Vault",
|
||||
href: "/vault",
|
||||
icon: "📓",
|
||||
description: "Browse and search zettels",
|
||||
},
|
||||
{
|
||||
label: "Presentations",
|
||||
href: "/presentations",
|
||||
icon: "🎤",
|
||||
description: "Presentation drafts and CI status",
|
||||
},
|
||||
{
|
||||
label: "Agents",
|
||||
href: "/agents",
|
||||
icon: "🤖",
|
||||
description: "Agent system health and status",
|
||||
},
|
||||
{
|
||||
label: "Pipeline",
|
||||
href: "/pipeline",
|
||||
icon: "🔧",
|
||||
description: "CI/CD runs and recent commits",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEPARTMENTS = [
|
||||
{ name: "@vault", role: "Knowledge management, Zettelkasten" },
|
||||
{ name: "@engineering", role: "Code, architecture, deployment" },
|
||||
{ name: "@market", role: "Competitive analysis, user research" },
|
||||
{ name: "@content", role: "Content voice, drafting" },
|
||||
{ name: "@trust", role: "Compliance, security, brand voice" },
|
||||
{ name: "@health", role: "Wellness, education, home, habits" },
|
||||
{ name: "@presentations", role: "Stagetime Agent, slides, coaching" },
|
||||
];
|
||||
|
||||
export const FORGEJO_URLS = {
|
||||
base: "http://192.168.168.221:3000",
|
||||
repos: "http://192.168.168.221:3000/user/repos",
|
||||
issues: "http://192.168.168.221:3000/issues",
|
||||
};
|
||||
|
||||
export const DASHBOARD_PORT = 3001;
|
||||
133
src/lib/forgejo-api.js
Normal file
133
src/lib/forgejo-api.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Forgejo API client
|
||||
// Reads token from env, proxies requests through Next.js API routes.
|
||||
|
||||
const FORGEJO_API_URL = process.env.FORGEJO_API_URL || "http://192.168.168.221:3000/api/v1";
|
||||
|
||||
function getHeaders() {
|
||||
const token = process.env.FORGEJO_TOKEN || "";
|
||||
return {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function forgejoFetch(path, options = {}) {
|
||||
const url = `${FORGEJO_API_URL}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...getHeaders(), ...options.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Forgejo API error: ${res.status} ${res.statusText} for ${path}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all repos for the authenticated user.
|
||||
*/
|
||||
export async function getRepos() {
|
||||
const repos = await forgejoFetch("/user/repos?limit=50");
|
||||
return repos.map(normalizeRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single repo by owner/name.
|
||||
*/
|
||||
export async function getRepo(owner, name) {
|
||||
const repo = await forgejoFetch(`/repos/${owner}/${name}`);
|
||||
return normalizeRepo(repo);
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches for a repo.
|
||||
*/
|
||||
export async function getBranches(owner, name) {
|
||||
return forgejoFetch(`/repos/${owner}/${name}/branches`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file contents from a repo.
|
||||
* Returns the decoded content string.
|
||||
*/
|
||||
export async function getFileContents(owner, name, filepath, ref = "main") {
|
||||
const data = await forgejoFetch(
|
||||
`/repos/${owner}/${name}/contents/${filepath}?ref=${ref}`
|
||||
);
|
||||
// Forgejo returns base64-encoded content for files
|
||||
if (data.content) {
|
||||
return Buffer.from(data.content, "base64").toString("utf-8");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search repos owned by the user matching a query.
|
||||
*/
|
||||
export async function searchUserRepos(query) {
|
||||
const repos = await forgejoFetch(`/user/repos?limit=50&q=${encodeURIComponent(query)}`);
|
||||
return repos.map(normalizeRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent commits across all user repos.
|
||||
* Returns last 5 commits from each repo flattened.
|
||||
*/
|
||||
export async function getRecentCommits(limit = 20) {
|
||||
const repos = await getRepos();
|
||||
const allCommits = [];
|
||||
for (const repo of repos.slice(0, 5)) {
|
||||
try {
|
||||
const commits = await forgejoFetch(
|
||||
`/repos/${repo.owner}/${repo.name}/commits?limit=5`
|
||||
);
|
||||
allCommits.push(
|
||||
...commits.map((c) => ({
|
||||
repo: repo.name,
|
||||
message: c.commit.message?.split("\n")[0] || "",
|
||||
author: c.commit.author?.name || "unknown",
|
||||
date: c.commit.author?.date || c.created_at,
|
||||
sha: c.sha?.substring(0, 7),
|
||||
url: c.html_url,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// skip repos we can't read
|
||||
}
|
||||
}
|
||||
return allCommits.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CI status for a repo (Actions runs).
|
||||
*/
|
||||
export async function getCIRuns(owner, name, limit = 10) {
|
||||
try {
|
||||
return await forgejoFetch(
|
||||
`/repos/${owner}/${name}/actions/runs?limit=${limit}`
|
||||
);
|
||||
} catch {
|
||||
return { total_count: 0, workflow_runs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRepo(repo) {
|
||||
return {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
description: repo.description || "",
|
||||
owner: repo.owner?.login || "sscoble",
|
||||
private: repo.private,
|
||||
default_branch: repo.default_branch || "main",
|
||||
html_url: repo.html_url,
|
||||
clone_url: repo.clone_url,
|
||||
language: repo.language || "",
|
||||
updated_at: repo.updated_at,
|
||||
pushed_at: repo.pushed_at,
|
||||
open_issues_count: repo.open_issues_count || 0,
|
||||
stars: repo.stars_count || 0,
|
||||
forks: repo.forks_count || 0,
|
||||
};
|
||||
}
|
||||
184
src/lib/vault-reader.js
Normal file
184
src/lib/vault-reader.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Vault reader — reads zettels from the local filesystem
|
||||
// These functions run server-side only (in API routes or server components).
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const matter = require("gray-matter");
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || path.join(process.env.HOME || "/home/sscoble", "vault");
|
||||
|
||||
/**
|
||||
* Recursively list all .md files in the vault.
|
||||
* Returns relative paths from vault root.
|
||||
*/
|
||||
export function listZettels() {
|
||||
const results = [];
|
||||
walkDir(VAULT_PATH, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
function walkDir(dir, results, relativePrefix = "") {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relPath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
// Skip hidden dirs and node_modules
|
||||
if (!entry.name.startsWith(".") && entry.name !== "node_modules") {
|
||||
walkDir(fullPath, results, relPath);
|
||||
}
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
const stat = fs.statSync(fullPath);
|
||||
results.push({
|
||||
path: relPath,
|
||||
name: entry.name,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single zettel by its relative path.
|
||||
* Returns parsed frontmatter + markdown body + metadata.
|
||||
*/
|
||||
export function getZettel(zettelPath) {
|
||||
const fullPath = path.join(VAULT_PATH, zettelPath);
|
||||
// Basic path traversal protection
|
||||
if (fullPath.indexOf(VAULT_PATH) !== 0) {
|
||||
throw new Error("Invalid path");
|
||||
}
|
||||
const raw = fs.readFileSync(fullPath, "utf-8");
|
||||
const parsed = matter(raw);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return {
|
||||
path: zettelPath,
|
||||
frontmatter: parsed.data || {},
|
||||
content: parsed.content,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
size: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search zettels by content query (simple grep).
|
||||
* Returns matching zettels with excerpt.
|
||||
*/
|
||||
export function searchZettels(query) {
|
||||
if (!query || query.trim() === "") return [];
|
||||
const results = [];
|
||||
const zettels = listZettels();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
for (const z of zettels) {
|
||||
const fullPath = path.join(VAULT_PATH, z.path);
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
if (content.toLowerCase().includes(lowerQuery)) {
|
||||
// Find the first matching line for excerpt
|
||||
const lines = content.split("\n");
|
||||
const matchLine = lines.findIndex((l) => l.toLowerCase().includes(lowerQuery));
|
||||
const excerpt = matchLine >= 0
|
||||
? lines.slice(Math.max(0, matchLine - 1), matchLine + 3).join("\n").substring(0, 300)
|
||||
: content.substring(0, 200);
|
||||
results.push({
|
||||
...z,
|
||||
excerpt: excerpt + (content.length > 300 ? "..." : ""),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all zettels for [[wiki-links]] and return a graph JSON.
|
||||
* Graph format: { nodes: [{id, name, group}], links: [{source, target}] }
|
||||
*/
|
||||
export function getGraph() {
|
||||
const zettels = listZettels();
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const nodeMap = new Map(); // path -> index
|
||||
|
||||
// First pass: create nodes
|
||||
zettels.forEach((z, i) => {
|
||||
const name = z.name.replace(/\.md$/, "");
|
||||
const fullPath = path.join(VAULT_PATH, z.path);
|
||||
let category = "uncategorized";
|
||||
try {
|
||||
const raw = fs.readFileSync(fullPath, "utf-8");
|
||||
const parsed = matter(raw);
|
||||
if (parsed.data?.category) {
|
||||
category = parsed.data.category;
|
||||
} else if (parsed.data?.tags) {
|
||||
category = Array.isArray(parsed.data.tags) ? parsed.data.tags[0] : parsed.data.tags;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
nodeMap.set(z.path, i);
|
||||
nodes.push({
|
||||
id: z.path,
|
||||
name,
|
||||
category,
|
||||
size: Math.max(5, Math.min(30, Math.round(z.size / 100))),
|
||||
});
|
||||
});
|
||||
|
||||
// Second pass: parse [[links]] in content and build edges
|
||||
const linkRegex = /\[\[([^\]]+)\]\]/g;
|
||||
zettels.forEach((z) => {
|
||||
const fullPath = path.join(VAULT_PATH, z.path);
|
||||
try {
|
||||
const raw = fs.readFileSync(fullPath, "utf-8");
|
||||
let match;
|
||||
while ((match = linkRegex.exec(raw)) !== null) {
|
||||
const targetName = match[1].trim();
|
||||
// Find the node with matching name
|
||||
for (const [nodePath, idx] of nodeMap) {
|
||||
const nodeName = nodePath.replace(/\.md$/, "").split("/").pop();
|
||||
if (nodeName === targetName || nodePath.includes(targetName)) {
|
||||
links.push({ source: nodeMap.get(z.path), target: idx });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently modified zettels.
|
||||
*/
|
||||
export function getRecentZettels(limit = 5) {
|
||||
const zettels = listZettels();
|
||||
return zettels
|
||||
.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
|
||||
.slice(0, limit)
|
||||
.map((z) => {
|
||||
const fullPath = path.join(VAULT_PATH, z.path);
|
||||
let frontmatter = {};
|
||||
let contentPreview = "";
|
||||
try {
|
||||
const raw = fs.readFileSync(fullPath, "utf-8");
|
||||
const parsed = matter(raw);
|
||||
frontmatter = parsed.data || {};
|
||||
contentPreview = parsed.content.substring(0, 150);
|
||||
} catch {
|
||||
contentPreview = "(unreadable)";
|
||||
}
|
||||
return { ...z, frontmatter, contentPreview };
|
||||
});
|
||||
}
|
||||
80
src/styles/globals.css
Normal file
80
src/styles/globals.css
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
--topbar-height: 56px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100 antialiased;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark mode (default) */
|
||||
.dark body {
|
||||
@apply bg-gray-950 text-gray-100;
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
.light body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-600;
|
||||
}
|
||||
|
||||
/* Prose for markdown content */
|
||||
.prose-zettel {
|
||||
@apply max-w-none text-gray-200 leading-relaxed;
|
||||
}
|
||||
.prose-zettel h1 { @apply text-2xl font-bold text-white mt-8 mb-4; }
|
||||
.prose-zettel h2 { @apply text-xl font-semibold text-gray-100 mt-6 mb-3; }
|
||||
.prose-zettel h3 { @apply text-lg font-medium text-gray-200 mt-5 mb-2; }
|
||||
.prose-zettel p { @apply my-3; }
|
||||
.prose-zettel a { @apply text-meos-400 hover:text-meos-300 underline; }
|
||||
.prose-zettel ul { @apply list-disc list-inside my-3 space-y-1; }
|
||||
.prose-zettel ol { @apply list-decimal list-inside my-3 space-y-1; }
|
||||
.prose-zettel code { @apply bg-gray-800 px-1.5 py-0.5 rounded text-sm text-meos-300; }
|
||||
.prose-zettel pre { @apply bg-gray-900 p-4 rounded-lg overflow-x-auto my-4; }
|
||||
.prose-zettel blockquote { @apply border-l-4 border-meos-500 pl-4 italic text-gray-400 my-4; }
|
||||
.prose-zettel hr { @apply border-gray-700 my-6; }
|
||||
|
||||
.light .prose-zettel {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
.light .prose-zettel h1,
|
||||
.light .prose-zettel h2,
|
||||
.light .prose-zettel h3 { @apply text-gray-900; }
|
||||
.light .prose-zettel code { @apply bg-gray-200 text-gray-800; }
|
||||
.light .prose-zettel pre { @apply bg-gray-100; }
|
||||
.light .prose-zettel blockquote { @apply text-gray-500; }
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-gray-900 border border-gray-800 rounded-xl p-4;
|
||||
}
|
||||
.light .card {
|
||||
@apply bg-white border-gray-200;
|
||||
}
|
||||
|
||||
/* Badge / tag */
|
||||
.tag {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-meos-900 text-meos-200;
|
||||
}
|
||||
.light .tag {
|
||||
@apply bg-meos-100 text-meos-800;
|
||||
}
|
||||
28
tailwind.config.js
Normal file
28
tailwind.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// MeOS brand colors — minimal, dark-first palette
|
||||
meos: {
|
||||
50: "#f0f9ff",
|
||||
100: "#e0f2fe",
|
||||
200: "#bae6fd",
|
||||
300: "#7dd3fc",
|
||||
400: "#38bdf8",
|
||||
500: "#0ea5e9",
|
||||
600: "#0284c7",
|
||||
700: "#0369a1",
|
||||
800: "#075985",
|
||||
900: "#0c4a6e",
|
||||
950: "#082f49",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue