feat(01-01): create Docker scaffold, FastAPI app shell, and sidebar templates

- Dockerfile with python:3.12-slim-bookworm, curl downloads Pico CSS, HTMX, Alpine.js at build time
- docker-compose.yml with imptune_data:/data named volume and restart policy
- requirements.txt with all phase 1-5 dependencies
- imptune/config.py loading DATA_DIR/PORT from env with DB_PATH and DRIVERS_DIR derived paths
- imptune/main.py: FastAPI app with StaticFiles mount, health+pages routers, startup dir creation
- imptune/api/health.py: GET /health returning {"status": "ok"}
- imptune/api/pages.py: GET / returning dashboard.html with sync def handler
- imptune/templates/base.html: data-theme="auto", sidebar with 5 nav sections, /static/ paths only
- imptune/templates/dashboard.html: quick actions + empty state recent activity
- imptune/static/app.css: sidebar layout, active link highlight, quick action buttons
This commit is contained in:
2026-04-10 11:25:08 +02:00
parent 4d455e7b19
commit bd4e132f82
12 changed files with 279 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim-bookworm
WORKDIR /app
# Install system deps, download frontend assets, purge curl in one layer
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& mkdir -p /app/imptune/static \
&& curl -sL --fail -o /app/imptune/static/pico.min.css \
"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" \
&& curl -sL --fail -o /app/imptune/static/htmx.min.js \
"https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
&& curl -sL --fail -o /app/imptune/static/alpine.min.js \
"https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" \
&& apt-get purge -y curl && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY imptune/ /app/imptune/
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
EXPOSE 8000
CMD ["uvicorn", "imptune.main:app", "--host", "0.0.0.0", "--port", "8000"]
+13
View File
@@ -0,0 +1,13 @@
services:
imptune:
build: .
ports:
- "8000:8000"
volumes:
- imptune_data:/data
restart: unless-stopped
environment:
- DATA_DIR=/data
volumes:
imptune_data:
View File
View File
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health():
return {"status": "ok"}
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"recent_printers": [],
"recent_packages": [],
},
)
+12
View File
@@ -0,0 +1,12 @@
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
DATA_DIR = os.environ.get("DATA_DIR", "/data")
PORT = int(os.environ.get("PORT", "8000"))
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
+25
View File
@@ -0,0 +1,25 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from imptune.api import health, pages
from imptune.config import DATA_DIR, DRIVERS_DIR
app = FastAPI(title="ImpTune")
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
_static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
# Register routers
app.include_router(health.router)
app.include_router(pages.router)
@app.on_event("startup")
def on_startup():
import os
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(DRIVERS_DIR, exist_ok=True)
+93
View File
@@ -0,0 +1,93 @@
/* ImpTune layout — supplements Pico CSS */
.layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--pico-muted-border-color, #e0e0e0);
padding: 1rem 0;
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 0.5rem 1rem 1rem;
font-size: 1.1rem;
border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
margin-bottom: 0.5rem;
}
.sidebar-nav {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-nav li {
margin: 0;
}
.sidebar-nav a {
display: block;
padding: 0.6rem 1rem;
text-decoration: none;
color: inherit;
font-weight: 400;
}
.sidebar-nav a:hover {
background-color: var(--pico-secondary-background, rgba(0,0,0,0.05));
}
.sidebar-nav a.active {
font-weight: 600;
background-color: var(--pico-primary-background, rgba(0,0,0,0.08));
border-left: 3px solid var(--pico-primary, #1a73e8);
}
/* Main content */
.main-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
/* Quick action buttons */
.quick-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.btn-action {
display: inline-block;
padding: 0.5rem 1rem;
border: 1px solid var(--pico-primary, #1a73e8);
border-radius: 4px;
text-decoration: none;
color: var(--pico-primary, #1a73e8);
font-weight: 500;
}
.btn-action[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Empty state */
.empty-state {
color: var(--pico-muted-color, #666);
font-style: italic;
}
/* Recent activity */
.activity-section {
margin-bottom: 1.5rem;
}
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ImpTune</title>
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="stylesheet" href="/static/app.css">
<script defer src="/static/alpine.min.js"></script>
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-brand">
<strong>ImpTune</strong>
</div>
<ul class="sidebar-nav">
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>Dashboard</a></li>
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>Drivers</a></li>
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}>Printers</a></li>
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}>Clients</a></li>
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>Packages</a></li>
</ul>
</nav>
<main class="main-content container">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<h1>Dashboard</h1>
<div class="quick-actions">
<a href="#" aria-disabled="true" class="btn-action">New Printer</a>
<a href="#" aria-disabled="true" class="btn-action">Upload Driver</a>
<a href="#" aria-disabled="true" class="btn-action">Export Package</a>
</div>
<section class="recent-activity">
<h2>Recent Activity</h2>
<div class="activity-section">
<h3>Recent Printers</h3>
{% if recent_printers %}
<ul>
{% for printer in recent_printers %}
<li>{{ printer.name }} — {{ printer.ip_address }}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No printers configured yet.</p>
{% endif %}
</div>
<div class="activity-section">
<h3>Recent Packages</h3>
{% if recent_packages %}
<ul>
{% for package in recent_packages %}
<li>{{ package }}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No packages exported yet.</p>
{% endif %}
</div>
</section>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.*
uvicorn[standard]==0.30.*
jinja2==3.1.*
python-multipart==0.0.9
pycryptodome==3.20.*
python-dotenv==1.0.*
peewee==3.17.*