update M05 frontend with htmx + Go templates for NAS deployment

This commit is contained in:
2026-01-18 20:18:51 +01:00
parent 366cc94d86
commit b86ee54934

View File

@@ -1,8 +1,48 @@
# Milestone 5: Developer Portal Frontend # Milestone 5: Developer Portal Frontend
**Status**: Planning **Status**: Decided
**Goal**: Web interface for developer account and app management. **Goal**: Web interface for developer account and app management.
## Decision
**htmx + Go Templates** for server-rendered UI from the single Go container:
```
Rendering: Go html/template
Interactivity: htmx (partial page updates)
Styling: Tailwind CSS (compiled at build time)
Charts: Chart.js (lightweight)
Icons: Heroicons or Lucide
Build: Embed static assets in Go binary
```
### Rationale
1. **Single container** - No separate Node.js server needed
2. **Server-rendered** - All HTML generated by Go templates
3. **htmx for interactivity** - AJAX without JavaScript framework
4. **Embedded assets** - CSS/JS bundled into Go binary via `embed`
5. **Simple deployment** - Just the Go binary, nothing else
6. **Low resource usage** - Perfect for Synology NAS
### Architecture
```
┌─────────────────────────────────────────┐
│ mosis-portal container │
│ ┌────────────────────────────────────┐ │
│ │ Go binary │ │
│ │ ├── Chi router │ │
│ │ ├── html/template rendering │ │
│ │ ├── Embedded static assets │ │
│ │ │ ├── tailwind.css (built) │ │
│ │ │ ├── htmx.min.js │ │
│ │ │ └── chart.min.js │ │
│ │ └── SQLite database │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
--- ---
## Overview ## Overview
@@ -246,136 +286,224 @@ Approach: npm package
--- ---
## Key Features ## Key Features (htmx Implementation)
### File Upload ### File Upload with Progress
```typescript ```html
// Drag and drop with progress <!-- htmx file upload with progress indicator -->
<DropZone <form hx-post="/api/apps/{{.App.ID}}/versions"
accept=".mosis" hx-encoding="multipart/form-data"
maxSize={50 * 1024 * 1024} hx-target="#upload-result"
onDrop={handleUpload} hx-indicator="#upload-spinner">
onProgress={setProgress}
> <div class="dropzone" id="dropzone">
<UploadIcon /> <input type="file" name="package" accept=".mosis" required
<p>Drop your package here</p> class="hidden" id="file-input">
</DropZone> <label for="file-input" class="cursor-pointer">
<svg><!-- upload icon --></svg>
<p>Drop your .mosis package here or click to browse</p>
<p class="text-sm text-gray-500">Max size: 50 MB</p>
</label>
</div>
<div id="upload-spinner" class="htmx-indicator">
Uploading...
</div>
<button type="submit" class="btn btn-primary">Upload</button>
</form>
<div id="upload-result"></div>
``` ```
### Real-time Validation ### Dynamic App List
```typescript ```html
// Validate package client-side before upload <!-- Server renders this, htmx updates it -->
async function validatePackage(file: File) { <div id="app-list"
const zip = await JSZip.loadAsync(file); hx-get="/partials/apps"
hx-trigger="load, newApp from:body"
// Check manifest hx-swap="innerHTML">
const manifest = await zip.file('manifest.json')?.async('text'); {{range .Apps}}
if (!manifest) throw new Error('Missing manifest.json'); <div class="app-card">
<img src="{{.IconURL}}" alt="{{.Name}}">
const parsed = JSON.parse(manifest); <h3>{{.Name}}</h3>
ManifestSchema.parse(parsed); <span class="badge {{.StatusClass}}">{{.Status}}</span>
<a href="/apps/{{.ID}}" hx-boost="true">View →</a>
// Check required files </div>
const entry = parsed.entry; {{end}}
if (!zip.file(entry)) { </div>
throw new Error(`Entry file not found: ${entry}`);
}
return parsed;
}
``` ```
### Analytics Dashboard ### Form with Validation
```typescript ```html
// Recharts example <!-- Create app form with server-side validation -->
<LineChart data={downloads}> <form hx-post="/apps" hx-target="#form-result" hx-swap="outerHTML">
<XAxis dataKey="date" /> <div class="form-group">
<YAxis /> <label for="name">App Name</label>
<Tooltip /> <input type="text" name="name" id="name" required
<Line type="monotone" dataKey="count" stroke="#8884d8" /> hx-post="/api/validate/name"
</LineChart> hx-trigger="blur"
hx-target="next .error">
<span class="error"></span>
</div>
<div class="form-group">
<label for="package_id">Package ID</label>
<input type="text" name="package_id" id="package_id"
placeholder="com.yourname.appname" required
hx-post="/api/validate/package-id"
hx-trigger="blur"
hx-target="next .error">
<span class="error"></span>
</div>
<button type="submit" class="btn btn-primary">Create App</button>
</form>
```
### Analytics Chart
```html
<!-- Chart.js for analytics -->
<div class="chart-container">
<canvas id="downloads-chart"></canvas>
</div>
<script>
// Data injected from Go template
const chartData = {{.ChartDataJSON}};
new Chart(document.getElementById('downloads-chart'), {
type: 'line',
data: {
labels: chartData.labels,
datasets: [{
label: 'Downloads',
data: chartData.values,
borderColor: '#6366f1',
tension: 0.3
}]
}
});
</script>
``` ```
--- ---
## State Management ## Go Template Structure
### Server State (React Query) ### Base Layout
```typescript ```go
// Fetch apps // templates/layouts/base.html
const { data: apps, isLoading } = useQuery({ {{define "base"}}
queryKey: ['apps'], <!DOCTYPE html>
queryFn: () => api.get('/apps'), <html lang="en">
}); <head>
<meta charset="UTF-8">
// Create app mutation <meta name="viewport" content="width=device-width, initial-scale=1.0">
const createApp = useMutation({ <title>{{.Title}} - Mosis Developer Portal</title>
mutationFn: (data) => api.post('/apps', data), <link href="/static/tailwind.css" rel="stylesheet">
onSuccess: () => { <script src="/static/htmx.min.js"></script>
queryClient.invalidateQueries(['apps']); </head>
}, <body class="bg-gray-50" hx-boost="true">
}); {{template "navbar" .}}
<main class="container mx-auto py-8">
{{template "content" .}}
</main>
</body>
</html>
{{end}}
``` ```
### Client State (Zustand) ### Page Template
```typescript ```go
// UI state // templates/pages/dashboard.html
const useStore = create((set) => ({ {{define "content"}}
sidebarOpen: true, <h1 class="text-2xl font-bold mb-6">Dashboard</h1>
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
})); <div class="grid grid-cols-3 gap-6 mb-8">
{{template "stat-card" dict "Label" "Total Apps" "Value" .Stats.TotalApps}}
{{template "stat-card" dict "Label" "Downloads" "Value" .Stats.Downloads}}
{{template "stat-card" dict "Label" "Active Users" "Value" .Stats.ActiveUsers}}
</div>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Your Apps</h2>
<a href="/apps/new" class="btn btn-primary">+ New App</a>
</div>
<div id="app-list" hx-get="/partials/apps" hx-trigger="load">
Loading...
</div>
{{end}}
``` ```
--- ---
## Authentication Flow ## Authentication Flow
### Login Page ### Login Page (Go Template)
```tsx ```html
export default function LoginPage() { {{define "content"}}
return ( <div class="min-h-screen flex items-center justify-center">
<div className="min-h-screen flex items-center justify-center"> <div class="card w-96 bg-white shadow-lg rounded-lg p-6">
<Card className="w-96"> <h1 class="text-2xl font-bold text-center mb-6">Sign in to Mosis</h1>
<CardHeader>
<h1>Sign in to Mosis</h1> <div class="space-y-4">
</CardHeader> <a href="/auth/github" class="btn btn-github w-full flex items-center justify-center gap-2">
<CardContent className="space-y-4"> <svg><!-- GitHub icon --></svg>
<Button onClick={() => signIn('github')} className="w-full"> Continue with GitHub
<GitHubIcon /> Continue with GitHub </a>
</Button>
<Button onClick={() => signIn('google')} className="w-full"> <a href="/auth/google" class="btn btn-google w-full flex items-center justify-center gap-2">
<GoogleIcon /> Continue with Google <svg><!-- Google icon --></svg>
</Button> Continue with Google
<Separator /> </a>
<form onSubmit={handleEmailLogin}>
<Input type="email" placeholder="Email" /> <div class="divider">or</div>
<Input type="password" placeholder="Password" />
<Button type="submit">Sign in</Button> <form hx-post="/auth/login" hx-target="#login-error" class="space-y-4">
</form> <input type="email" name="email" placeholder="Email"
</CardContent> class="input input-bordered w-full" required>
</Card> <input type="password" name="password" placeholder="Password"
class="input input-bordered w-full" required>
<div id="login-error" class="text-red-500 text-sm"></div>
<button type="submit" class="btn btn-primary w-full">Sign in</button>
</form>
</div> </div>
); </div>
} </div>
{{end}}
``` ```
### Protected Routes ### Auth Middleware (Go)
```tsx ```go
// Middleware (Next.js) // middleware/auth.go
export function middleware(request: NextRequest) { func RequireAuth(next http.Handler) http.Handler {
const token = request.cookies.get('token'); return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := sessionStore.Get(r, "session")
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { if err != nil || session.Values["developer_id"] == nil {
return NextResponse.redirect(new URL('/login', request.url)); http.Redirect(w, r, "/login", http.StatusSeeOther)
} return
}
next.ServeHTTP(w, r)
})
} }
// Usage in router
r.Group(func(r chi.Router) {
r.Use(RequireAuth)
r.Get("/dashboard", handlers.Dashboard)
r.Get("/apps", handlers.AppList)
r.Get("/apps/{id}", handlers.AppDetail)
})
``` ```
--- ---
@@ -433,11 +561,11 @@ npm install @axe-core/react
## Deliverables ## Deliverables
- [ ] Framework selection - [x] Framework selection (htmx + Go Templates)
- [ ] UI component library selection - [x] UI component library selection (Tailwind CSS + Chart.js)
- [ ] Design system (colors, typography) - [ ] Design system (colors, typography)
- [ ] Page wireframes - [x] Page wireframes (see above)
- [ ] Authentication flow - [x] Authentication flow (server sessions + OAuth)
- [ ] Dashboard implementation - [ ] Dashboard implementation
- [ ] App management pages - [ ] App management pages
- [ ] Version submission flow - [ ] Version submission flow
@@ -449,10 +577,10 @@ npm install @axe-core/react
## Open Questions ## Open Questions
1. Dark mode support? 1. ~~Dark mode support?~~ → Defer to post-MVP (Tailwind makes it easy to add later)
2. Internationalization (i18n)? 2. ~~Internationalization (i18n)?~~ → English only for MVP
3. Custom domain for docs vs integrated? 3. ~~Custom domain for docs vs integrated?~~ → Integrated into same Go server at /docs
4. Email notifications UI? 4. Email notifications UI? → Consider for v1.1
--- ---