""" HoffDesk Blog — Dev Server Serves the blog frontend, proxies API calls to real hoffdesk-api. Run: python dev_server.py """ from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape from pathlib import Path from datetime import datetime, timezone, timedelta import httpx import asyncio API_BASE = "http://127.0.0.1:8000" app = FastAPI() BASE = Path(__file__).parent CST = timezone(timedelta(hours=-5)) # Jinja2 setup with direct Environment def format_date_filter(value): """Simple date formatter for Jinja2""" if isinstance(value, str): try: value = datetime.fromisoformat(value.replace('Z', '+00:00')) except: return value if isinstance(value, datetime): return value.strftime("%B %d, %Y") return value # Main blog templates blog_env = Environment( loader=FileSystemLoader(str(BASE / "templates")), autoescape=select_autoescape(['html', 'xml']) ) blog_env.filters["format_date"] = format_date_filter # Admin templates admin_env = Environment( loader=FileSystemLoader(str(BASE / "admin" / "templates")), autoescape=select_autoescape(['html', 'xml']) ) admin_env.filters["format_date"] = format_date_filter CATEGORY_LABELS = { "homelab": "Home Lab", "openclaw": "OpenClaw", "ai-news": "AI News" } # --- Mock Data --- MOCK_POSTS = [ { "slug": "the-night-i-broke-dns", "title": "The Night I Broke DNS and My Wife Couldn't Reach Facebook", "category": "homelab", "status": "published", "tags": "dns, pihole, homelab, fail, marriage", "excerpt": "A cautionary tale about late-night infrastructure changes and the importance of testing your DNS failover before your wife tries to scroll Instagram.", "cover_image": "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800&h=400&fit=crop", "content": """# The Night I Broke DNS It was 11:47 PM...""", "created_at": "2026-04-20T08:00:00Z", "updated_at": "2026-04-21T10:30:00Z", "updated_relative": "2 hours ago", "reading_time_minutes": 4 }, { "slug": "openclaw-agent-setup", "title": "Teaching OpenClaw to Recognize Itself", "category": "openclaw", "status": "published", "tags": "openclaw, agents, llm, self-hosting", "excerpt": "How we gave OpenClaw agents persistent identity through SOUL.md and why it matters for sovereign AI.", "cover_image": "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&h=400&fit=crop", "content": "# Teaching OpenClaw to Recognize Itself\n\nDraft content here...", "created_at": "2026-04-18T14:00:00Z", "updated_at": "2026-04-20T16:00:00Z", "updated_relative": "1 day ago", "reading_time_minutes": 8 }, { "slug": "glm-5-vs-local-models", "title": "GLM-5 vs Local 70B: A Week of Testing", "category": "ai-news", "status": "published", "tags": "glm-5, local-llm, benchmarks, comparison", "excerpt": "I ran GLM-5 and Qwen2.5-72B side by side for a week. The results surprised me.", "cover_image": "https://images.unsplash.com/photo-1620712943543-bcc4688e7485?w=800&h=400&fit=crop", "content": "# GLM-5 vs Local 70B\n\nTesting results here...", "created_at": "2026-04-15T09:00:00Z", "updated_at": "2026-04-15T09:00:00Z", "updated_relative": "6 days ago", "reading_time_minutes": 12 }, { "slug": "tailscale-acl-lessons", "title": "The Day I Accidentally Exposed My CalDAV to the Internet", "category": "homelab", "status": "published", "tags": "tailscale, security, caldav, homelab", "excerpt": "I thought I had set up Tailscale ACLs correctly. Then I ran nmap. Here's what I found and how I fixed it.", "cover_image": "https://images.unsplash.com/photo-1551808525-51a94da548ce?w=800&h=400&fit=crop", "content": "# Tailscale ACL Lessons\n\nIt started with a simple question...", "created_at": "2026-04-12T10:00:00Z", "updated_at": "2026-04-12T10:00:00Z", "updated_relative": "1 week ago", "reading_time_minutes": 6 }, { "slug": "building-family-dashboard", "title": "Building a Family Dashboard That Actually Gets Used", "category": "homelab", "status": "published", "tags": "family, dashboard, python, fastapi", "excerpt": "Most family dashboards fail within a week. Here's how I built one that Aundrea actually opens every morning.", "cover_image": "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=400&fit=crop", "content": "# Building a Family Dashboard\n\nThe first version died in three days...", "created_at": "2026-04-08T08:00:00Z", "updated_at": "2026-04-10T12:00:00Z", "updated_relative": "2 weeks ago", "reading_time_minutes": 9 }, { "slug": "openclaw-agents-101", "title": "OpenClaw Agents Explained: Why Your Butler Needs a SOUL", "category": "openclaw", "status": "published", "tags": "openclaw, agents, tutorial, self-hosting", "excerpt": "The difference between a stateless chatbot and a persistent assistant comes down to one file: SOUL.md.", "cover_image": "https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=400&fit=crop", "content": "# OpenClaw Agents 101\n\nMost AI assistants are stateless...", "created_at": "2026-04-05T14:00:00Z", "updated_at": "2026-04-05T14:00:00Z", "updated_relative": "2 weeks ago", "reading_time_minutes": 7 }, { "slug": "local-llm-ama", "title": "I Hosted an LLM Locally for a Month. Here's What I Learned.", "category": "ai-news", "status": "published", "tags": "llm, local-llm, ollama, homelab", "excerpt": "Spoiler: it's not about the GPU. It's about the workflow. A month with Qwen2.5 on my Beelink.", "cover_image": "https://images.unsplash.com/photo-1591696205602-2f950c417cb9?w=800&h=400&fit=crop", "content": "# Local LLM AMA\n\nEveryone asks about the GPU...", "created_at": "2026-04-01T09:00:00Z", "updated_at": "2026-04-01T09:00:00Z", "updated_relative": "3 weeks ago", "reading_time_minutes": 11 }, { "slug": "beelink-upgrade-story", "title": "Why I Upgraded from a Raspberry Pi to a Beelink", "category": "homelab", "status": "published", "tags": "beelink, homelab, hardware, raspberry-pi", "excerpt": "The Pi was fine until it wasn't. Three use cases that made me finally switch to a real x86 box.", "cover_image": "https://images.unsplash.com/photo-1587831990711-23ca6441447b?w=800&h=400&fit=crop", "content": "# Beelink Upgrade Story\n\nThe Pi held on for four years...", "created_at": "2026-03-25T10:00:00Z", "updated_at": "2026-03-25T10:00:00Z", "updated_relative": "1 month ago", "reading_time_minutes": 5 }, { "slug": "openclaw-skill-writing", "title": "Writing Your First OpenClaw Skill: A Practical Guide", "category": "openclaw", "status": "published", "tags": "openclaw, skills, tutorial, python", "excerpt": "Skills are how you extend OpenClaw. Here's how I wrote a skill to control my Hue lights in 30 minutes.", "cover_image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&h=400&fit=crop", "content": "# Writing Your First OpenClaw Skill\n\nSkills are the building blocks...", "created_at": "2026-03-20T14:00:00Z", "updated_at": "2026-03-20T14:00:00Z", "updated_relative": "1 month ago", "reading_time_minutes": 8 } ] MOCK_STATS = { "total": 3, "published": 2, "drafts": 1, "images": 5, "last_build_time": "2026-04-21T10:30:00Z", "last_build_iso": "2026-04-21T10:30:00+00:00" } MOCK_IMAGES = [ { "path": "/blog/posts/the-night-i-broke-dns/images/hero.svg", "filename": "hero.svg", "relative_path": "posts/the-night-i-broke-dns/images/hero.svg", "size_bytes": 4500, "dimensions": "1200x600", "modified": "2026-04-20T08:00:00Z" }, { "path": "/blog/images/diagrams/network-topology.svg", "filename": "network-topology.svg", "relative_path": "images/diagrams/network-topology.svg", "size_bytes": 8200, "dimensions": "800x600", "modified": "2026-04-19T10:00:00Z" } ] # --- Helper Functions --- def get_post(slug): for post in MOCK_POSTS: if post["slug"] == slug: return post return None def render_template(env, template_name, context): """Render a Jinja2 template with context""" template = env.get_template(template_name) return template.render(**context) # --- Public Blog Routes --- @app.get("/", response_class=HTMLResponse) async def blog_index(request: Request): published = [p for p in MOCK_POSTS if p["status"] == "published"] featured = published[0] if published else None html = render_template(blog_env, "blog_index.html.j2", { "request": request, "posts": published, "featured_post": featured, "categories": ["homelab", "openclaw", "ai-news"], "current_category": None, "category_labels": CATEGORY_LABELS, "page_title": "HoffDesk Blog", "page_description": "Home lab experiments, OpenClaw tutorials, and AI insights from a sovereign infrastructure journey.", "current_page": 1, "total_pages": 1 }) return HTMLResponse(html) @app.get("/category/{category}", response_class=HTMLResponse) async def blog_category(request: Request, category: str): posts = [p for p in MOCK_POSTS if p["category"] == category and p["status"] == "published"] html = render_template(blog_env, "blog_category.html.j2", { "request": request, "posts": posts, "category": category, "category_label": CATEGORY_LABELS.get(category, category), "categories": ["homelab", "openclaw", "ai-news"], "current_category": category, "category_labels": CATEGORY_LABELS, "page_title": f"{CATEGORY_LABELS.get(category, category)} — HoffDesk Blog", "page_description": f"Posts about {CATEGORY_LABELS.get(category, category)}", "current_page": 1, "total_pages": 1 }) return HTMLResponse(html) @app.get("/tag/{tag}", response_class=HTMLResponse) async def blog_tag(request: Request, tag: str): posts = [p for p in MOCK_POSTS if tag in p.get("tags", "") and p["status"] == "published"] html = render_template(blog_env, "blog_tag.html.j2", { "request": request, "posts": posts, "tag": tag, "categories": ["homelab", "openclaw", "ai-news"], "category_labels": CATEGORY_LABELS, "page_title": f"Posts tagged '{tag}' — HoffDesk Blog", "page_description": f"All posts tagged with {tag}", "current_page": 1, "total_pages": 1 }) return HTMLResponse(html) @app.get("/article/{slug}", response_class=HTMLResponse) async def blog_article(request: Request, slug: str): post = get_post(slug) if not post or post["status"] != "published": return HTMLResponse("Not found", status_code=404) related = [p for p in MOCK_POSTS if p["category"] == post["category"] and p["slug"] != slug and p["status"] == "published"][:3] html = render_template(blog_env, "blog_article.html.j2", { "request": request, "post": post, "related_posts": related, "categories": ["homelab", "openclaw", "ai-news"], "category_labels": CATEGORY_LABELS, "page_title": f"{post['title']} — HoffDesk Blog", "page_description": post.get("excerpt", ""), "author_name": "Matt Hoffmann", "site_name": "HoffDesk Blog" }) return HTMLResponse(html) @app.get("/feed.xml", response_class=HTMLResponse) async def blog_feed(request: Request): published = [p for p in MOCK_POSTS if p["status"] == "published"] html = render_template(blog_env, "feed.xml.j2", { "request": request, "posts": published, "last_build_date": MOCK_STATS["last_build_time"] }) return HTMLResponse(html, media_type="application/xml") @app.get("/sitemap.xml", response_class=HTMLResponse) async def blog_sitemap(request: Request): published = [p for p in MOCK_POSTS if p["status"] == "published"] html = render_template(blog_env, "sitemap.xml.j2", { "request": request, "posts": published }) return HTMLResponse(html, media_type="application/xml") # --- Admin Routes --- @app.get("/admin", response_class=HTMLResponse) @app.get("/admin/", response_class=HTMLResponse) async def admin_dashboard(request: Request): recent = sorted(MOCK_POSTS, key=lambda p: p["updated_at"], reverse=True)[:5] html = render_template(admin_env, "admin_dashboard.html.j2", { "request": request, "stats": MOCK_STATS, "recent_posts": recent, "draft_count": MOCK_STATS["drafts"], "current_view": "dashboard" }) return HTMLResponse(html) @app.get("/admin/posts", response_class=HTMLResponse) async def admin_post_list(request: Request, status: str = None, category: str = None, q: str = None): posts = MOCK_POSTS if status: posts = [p for p in posts if p["status"] == status] if category: posts = [p for p in posts if p["category"] == category] if q: posts = [p for p in posts if q.lower() in p["title"].lower()] html = render_template(admin_env, "admin_post_list.html.j2", { "request": request, "posts": posts, "filter_status": status, "filter_category": category, "search_query": q, "draft_count": MOCK_STATS["drafts"], "current_view": "all_posts" if not status else ("drafts" if status == "draft" else "published") }) return HTMLResponse(html) @app.get("/admin/posts/new", response_class=HTMLResponse) async def admin_editor_new(request: Request): html = render_template(admin_env, "admin_editor.html.j2", { "request": request, "post": None, "draft_count": MOCK_STATS["drafts"], "current_view": "editor" }) return HTMLResponse(html) @app.get("/admin/posts/{slug}/edit", response_class=HTMLResponse) async def admin_editor_edit(request: Request, slug: str): post = get_post(slug) if not post: return HTMLResponse("Not found", status_code=404) html = render_template(admin_env, "admin_editor.html.j2", { "request": request, "post": post, "draft_count": MOCK_STATS["drafts"], "current_view": "editor" }) return HTMLResponse(html) @app.get("/admin/images", response_class=HTMLResponse) async def admin_images(request: Request): html = render_template(admin_env, "admin_images.html.j2", { "request": request, "images": MOCK_IMAGES, "draft_count": MOCK_STATS["drafts"], "current_view": "images" }) return HTMLResponse(html) # --- Admin API Endpoints (Mock) --- @app.get("/api/blog/admin/stats") async def api_admin_stats(): return MOCK_STATS @app.get("/api/blog/admin/recent") async def api_admin_recent(): recent = sorted(MOCK_POSTS, key=lambda p: p["updated_at"], reverse=True)[:5] return {"posts": recent} @app.get("/api/blog/admin/posts") async def api_admin_posts(status: str = None, category: str = None, q: str = None, page: int = 1, per_page: int = 20): posts = MOCK_POSTS if status: posts = [p for p in posts if p["status"] == status] if category: posts = [p for p in posts if p["category"] == category] if q: posts = [p for p in posts if q.lower() in p["title"].lower()] return { "posts": posts, "pagination": { "page": page, "per_page": per_page, "total": len(posts), "pages": 1 } } @app.get("/api/blog/admin/posts/{slug}") async def api_admin_post(slug: str): post = get_post(slug) if not post: return JSONResponse({"error": "Not found"}, status_code=404) return post @app.post("/api/blog/admin/posts/{slug}") async def api_admin_save_post(slug: str, request: Request): return HTMLResponse('') @app.post("/api/blog/admin/posts/{slug}/publish") async def api_admin_publish_post(slug: str, request: Request): post = get_post(slug) if post: post["status"] = "published" html = render_template(admin_env, "components/status_widget.html.j2", { "request": request, "post": post or {"status": "published"} }) return HTMLResponse(html) @app.post("/api/blog/admin/posts/{slug}/unpublish") async def api_admin_unpublish_post(slug: str, request: Request): post = get_post(slug) if post: post["status"] = "draft" html = render_template(admin_env, "components/status_widget.html.j2", { "request": request, "post": post or {"status": "draft"} }) return HTMLResponse(html) @app.post("/api/blog/admin/posts/{slug}/delete") async def api_admin_delete_post(slug: str): return HTMLResponse('') @app.post("/api/blog/admin/posts/{slug}/preview") async def api_admin_preview_post(slug: str, request: Request): return {"rendered_html": "
Content would render here...
"} @app.post("/api/blog/admin/rebuild") async def api_admin_rebuild(): return HTMLResponse('') @app.post("/api/blog/admin/deploy") async def api_admin_deploy(): return HTMLResponse('') @app.get("/api/blog/admin/images") async def api_admin_images(): return {"images": MOCK_IMAGES} # --- Content Generation Proxy (proxies to real API) --- @app.get("/admin/content/health") async def content_health(): """Proxy health check to real API""" async with httpx.AsyncClient() as client: resp = await client.get(f"{API_BASE}/admin/content/health") return resp.json() @app.post("/admin/content/generate") async def content_generate(request: Request): """Proxy generation request to real API""" data = await request.json() async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post(f"{API_BASE}/admin/content/generate", json=data) return resp.json() @app.get("/admin/content/jobs/{job_id}") async def content_job_status(job_id: str): """Proxy job status polling to real API""" async with httpx.AsyncClient() as client: resp = await client.get(f"{API_BASE}/admin/content/jobs/{job_id}") if resp.status_code == 404: return JSONResponse({"error": "Job not found", "status": "not_found"}, status_code=404) return resp.json() @app.post("/admin/content/jobs/{job_id}/cancel") async def content_cancel_job(job_id: str): """Proxy cancel request to real API""" async with httpx.AsyncClient() as client: resp = await client.post(f"{API_BASE}/admin/content/jobs/{job_id}/cancel") return resp.json() # --- Static Files --- app.mount("/blog/static", StaticFiles(directory=str(BASE / "static")), name="blog_static") app.mount("/blog/admin/static", StaticFiles(directory=str(BASE / "admin" / "static")), name="blog_admin_static") app.mount("/blog/posts", StaticFiles(directory=str(BASE / "posts")), name="posts") if __name__ == "__main__": import uvicorn print("🎨 HoffDesk Blog Dev Server") print("Public blog: http://localhost:8080/") print("Admin panel: http://localhost:8080/admin/") uvicorn.run(app, host="0.0.0.0", port=8080)