commit 0b7deee59e6adbb75c50baa5359c8f97ce08d44d Author: dtoro Date: Tue Mar 31 17:21:33 2026 +0200 feat: init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38faa3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +node_modules/ +frontend/dist/ +.env +*.db +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf2102a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ ./ +COPY frontend/dist/ ./static/ + +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8362508 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Micronomicon + +A self-hosted web editor for writing Markdown and publishing `.mu` pages to a NomadNet node. + +Write in Markdown → preview as Micron → publish directly to `~/.nomadnetwork/storage/pages/`. + +--- + +## Requirements + +- Docker + Docker Compose +- Python 3.13+ (for local backend development only) +- Node 20+ (for local frontend development only) +- A running NomadNet container named `nomadnet` (for the restart button) + +--- + +## Quick Start (Docker) + +```bash +# 1. Build the frontend +cd frontend +npm install +npm run build +cd .. + +# 2. Create source directories +mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources + +# 3. Start the stack +docker compose up --build +``` + +App is available at `http://localhost:8080`. + +### Tailscale HTTPS + +```bash +tailscale serve --bg https+insecure://localhost:8080 +``` + +--- + +## Local Development + +Run backend and frontend separately with hot reload. + +**Backend** + +```bash +cd backend +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +PAGES_DIR=~/.nomadnetwork/storage/pages \ +SOURCES_DIR=~/.micron-editor/sources \ +uvicorn main:app --reload --port 8080 +``` + +**Frontend** + +```bash +cd frontend +npm install +npm run dev # proxies /api → localhost:8080 +``` + +Open `http://localhost:5173`. + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------------------|------------------|--------------------------------------| +| `PAGES_DIR` | `/data/pages` | NomadNet pages directory | +| `SOURCES_DIR` | `/data/sources` | Markdown source files directory | +| `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart | + +--- + +## API Reference + +| Method | Path | Description | +|----------|---------------------|-----------------------------------------------| +| `GET` | `/api/health` | Health check | +| `POST` | `/api/convert` | Convert `{ markdown }` → `{ micron }` | +| `GET` | `/api/pages` | List all pages with metadata | +| `GET` | `/api/pages/{name}` | Read page (markdown source + micron output) | +| `POST` | `/api/pages/{name}` | Save `{ markdown, publish }` — draft or live | +| `DELETE` | `/api/pages/{name}` | Delete source and/or `.mu` file | +| `GET` | `/api/graph` | Graph nodes + edges from parsed link sources | +| `POST` | `/api/restart` | Restart NomadNet Docker container | + +--- + +## Directory Layout + +``` +micronomicon/ + Dockerfile + compose.yml + backend/ + main.py ← FastAPI app + static file serving + converter.py ← md2txt wrapper (POST /api/convert) + pages.py ← file management (CRUD /api/pages) + graph.py ← link parser (GET /api/graph) + docker_utils.py ← container restart (POST /api/restart) + requirements.txt + frontend/ + src/ + App.tsx + routes/ ← DashboardView, EditorView, GraphView + components/ ← dashboard/, editor/, shared/, ui/ (shadcn) + stores/ ← editorStore, pagesStore (Zustand) + hooks/ ← useConversion, useGraph, useUnsavedGuard + lib/ ← utils (cn) + +~/.nomadnetwork/storage/pages/ ← published .mu files (NomadNet serves these) +~/.micron-editor/sources/ ← markdown sources (managed by this app) +``` + +--- + +## Page Lifecycle + +``` +New Page → /editor/new → Save Draft → .md saved to sources/ + → Publish → .md saved + .mu written to pages/ +``` + +- **Draft** — `.md` exists, no `.mu`. Not visible on NomadNet. +- **Published** — both `.md` and `.mu` exist. +- **Orphan** — `.mu` exists but no `.md` source (e.g. pages created outside this tool). + +--- + +## Tech Stack + +| Layer | Technology | +|-----------|-----------------------------------------| +| Backend | Python 3.13 + FastAPI + uvicorn | +| Converter | md2txt (micron renderer) | +| Frontend | React 19 + Vite + TypeScript | +| UI | shadcn/ui + Tailwind CSS v4 | +| Editor | CodeMirror 6 | +| Graph | React Flow + dagre | +| State | Zustand | +| Container | Docker + Compose | + +--- + +## Known Limitations (Phase 1) + +- Micron preview is plain text — full terminal rendering comes in a later phase (micron-parser-js iframe) +- `[[` link autocomplete not yet implemented (Phase 2) +- Graph view is read-only; click a node to open it in the editor +- No metrics (Phase 4) diff --git a/backend/converter.py b/backend/converter.py new file mode 100644 index 0000000..628c438 --- /dev/null +++ b/backend/converter.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter() + + +class ConvertRequest(BaseModel): + markdown: str + width: int = 80 + + +class ConvertResponse(BaseModel): + micron: str + + +@router.post("/convert", response_model=ConvertResponse) +async def convert(req: ConvertRequest): + try: + from md2txt import convert_markdown + + result = convert_markdown( + req.markdown, + width=req.width, + renderer_name="micron", + ) + return ConvertResponse(micron=result) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Conversion failed: {e}") diff --git a/backend/docker_utils.py b/backend/docker_utils.py new file mode 100644 index 0000000..c735742 --- /dev/null +++ b/backend/docker_utils.py @@ -0,0 +1,25 @@ +import os + +from fastapi import APIRouter, HTTPException + +router = APIRouter() + +NOMADNET_CONTAINER = os.environ.get("NOMADNET_CONTAINER", "nomadnet") + + +@router.post("/restart") +async def restart_nomadnet(): + try: + import docker + + client = docker.from_env() + container = client.containers.get(NOMADNET_CONTAINER) + container.restart() + return {"status": "restarted", "container": NOMADNET_CONTAINER} + except docker.errors.NotFound: + raise HTTPException( + status_code=404, + detail=f"Container '{NOMADNET_CONTAINER}' not found", + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/graph.py b/backend/graph.py new file mode 100644 index 0000000..7584f31 --- /dev/null +++ b/backend/graph.py @@ -0,0 +1,81 @@ +import os +import re +from pathlib import Path + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + +SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) +PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) + +# Matches markdown links: [text](slug) where slug has no protocol or path separators +_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)") + + +class GraphNode(BaseModel): + id: str + published: bool + title: str | None = None + + +class GraphEdge(BaseModel): + source: str + target: str + + +class GraphData(BaseModel): + nodes: list[GraphNode] + edges: list[GraphEdge] + + +def _all_page_names() -> set[str]: + names: set[str] = set() + if PAGES_DIR.is_dir(): + for f in PAGES_DIR.iterdir(): + if f.suffix == ".mu" and f.is_file(): + names.add(f.stem) + if SOURCES_DIR.is_dir(): + for f in SOURCES_DIR.iterdir(): + if f.suffix == ".md" and f.is_file(): + names.add(f.stem) + return names + + +def _extract_title(markdown: str) -> str | None: + for line in markdown.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return None + + +@router.get("/graph", response_model=GraphData) +async def get_graph(): + all_names = _all_page_names() + nodes: list[GraphNode] = [] + edges: list[GraphEdge] = [] + + for name in sorted(all_names): + md_path = SOURCES_DIR / f"{name}.md" + mu_path = PAGES_DIR / f"{name}.mu" + + title = None + if md_path.is_file(): + content = md_path.read_text(encoding="utf-8") + title = _extract_title(content) + + # Parse internal links + for match in _INTERNAL_LINK.finditer(content): + target = match.group(2) + if target in all_names: + edges.append(GraphEdge(source=name, target=target)) + + nodes.append(GraphNode( + id=name, + published=mu_path.is_file(), + title=title, + )) + + return GraphData(nodes=nodes, edges=edges) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..fe5101c --- /dev/null +++ b/backend/main.py @@ -0,0 +1,28 @@ +import os +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from converter import router as converter_router +from pages import router as pages_router +from graph import router as graph_router +from docker_utils import router as docker_router + +app = FastAPI(title="Micron Page Editor") + +app.include_router(converter_router, prefix="/api") +app.include_router(pages_router, prefix="/api") +app.include_router(graph_router, prefix="/api") +app.include_router(docker_router, prefix="/api") + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} + + +# Serve built frontend as static files (SPA fallback) +static_dir = Path(__file__).parent / "static" +if static_dir.is_dir(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") diff --git a/backend/pages.py b/backend/pages.py new file mode 100644 index 0000000..b9aeb3c --- /dev/null +++ b/backend/pages.py @@ -0,0 +1,136 @@ +import os +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter() + +PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) +SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) + + +class PageMeta(BaseModel): + name: str + title: str | None = None + published: bool = False + has_source: bool = False + last_modified: float | None = None + size: int | None = None + + +class PageDetail(BaseModel): + name: str + markdown: str | None = None + micron: str | None = None + + +class SaveRequest(BaseModel): + markdown: str + publish: bool = False + + +def _extract_title(markdown: str) -> str | None: + for line in markdown.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return None + + +def _list_all_page_names() -> set[str]: + names: set[str] = set() + if PAGES_DIR.is_dir(): + for f in PAGES_DIR.iterdir(): + if f.suffix == ".mu" and f.is_file(): + names.add(f.stem) + if SOURCES_DIR.is_dir(): + for f in SOURCES_DIR.iterdir(): + if f.suffix == ".md" and f.is_file(): + names.add(f.stem) + return names + + +def _page_meta(name: str) -> PageMeta: + mu_path = PAGES_DIR / f"{name}.mu" + md_path = SOURCES_DIR / f"{name}.md" + + title = None + if md_path.is_file(): + title = _extract_title(md_path.read_text(encoding="utf-8")) + + published = mu_path.is_file() + last_modified = mu_path.stat().st_mtime if published else None + size = mu_path.stat().st_size if published else None + + return PageMeta( + name=name, + title=title, + published=published, + has_source=md_path.is_file(), + last_modified=last_modified, + size=size, + ) + + +@router.get("/pages", response_model=list[PageMeta]) +async def list_pages(): + return [_page_meta(n) for n in sorted(_list_all_page_names())] + + +@router.get("/pages/{name}", response_model=PageDetail) +async def get_page(name: str): + md_path = SOURCES_DIR / f"{name}.md" + mu_path = PAGES_DIR / f"{name}.mu" + + if not md_path.is_file() and not mu_path.is_file(): + raise HTTPException(status_code=404, detail="Page not found") + + markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None + micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None + + return PageDetail(name=name, markdown=markdown, micron=micron) + + +@router.post("/pages/{name}", response_model=PageMeta) +async def save_page(name: str, req: SaveRequest): + SOURCES_DIR.mkdir(parents=True, exist_ok=True) + PAGES_DIR.mkdir(parents=True, exist_ok=True) + + # Always save markdown source + md_path = SOURCES_DIR / f"{name}.md" + md_path.write_text(req.markdown, encoding="utf-8") + + # Optionally publish + if req.publish: + try: + from md2txt import convert_markdown + + micron = convert_markdown( + req.markdown, + width=80, + renderer_name="micron", + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Conversion failed: {e}") + + mu_path = PAGES_DIR / f"{name}.mu" + mu_path.write_text(micron, encoding="utf-8") + + return _page_meta(name) + + +@router.delete("/pages/{name}") +async def delete_page(name: str): + md_path = SOURCES_DIR / f"{name}.md" + mu_path = PAGES_DIR / f"{name}.mu" + + if not md_path.is_file() and not mu_path.is_file(): + raise HTTPException(status_code=404, detail="Page not found") + + if md_path.is_file(): + md_path.unlink() + if mu_path.is_file(): + mu_path.unlink() + + return {"deleted": name} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..67ebee9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +docker>=7.0 +md2txt @ git+https://codeberg.org/randogoth/md2txt diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f7425f6 --- /dev/null +++ b/compose.yml @@ -0,0 +1,14 @@ +services: + micron-editor: + build: . + ports: + - "127.0.0.1:8080:8080" + volumes: + - ~/.nomadnetwork/storage/pages:/data/pages + - ~/.micron-editor/sources:/data/sources + - /var/run/docker.sock:/var/run/docker.sock + environment: + - PAGES_DIR=/data/pages + - SOURCES_DIR=/data/sources + - NOMADNET_CONTAINER=nomadnet + restart: always