Initial commit: MeOS Dashboard v0.1 — Next.js 14, Forgejo API layer, Tailwind CSS

This commit is contained in:
Scott Scoble 2026-04-25 22:32:05 -07:00
commit 1ac10cb630
35 changed files with 9357 additions and 0 deletions

14
.env.local.example Normal file
View 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
View file

@ -0,0 +1,6 @@
node_modules/
.next/
.env.local
*.log
.next/
out/

130
README.md Normal file
View 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
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

10
next.config.js Normal file
View 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

File diff suppressed because it is too large Load diff

28
package.json Normal file
View 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
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

100
src/app/agents/page.js Normal file
View 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>
);
}

View 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`;
}

View 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 }
);
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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
View 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
View 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
View 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> &middot;{" "}
<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>
);
}

View 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>
);
}

View 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} &middot;{" "}
{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
View 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} &middot;{" "}
{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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: [],
};