update M05 frontend with htmx + Go templates for NAS deployment
This commit is contained in:
@@ -1,8 +1,48 @@
|
||||
# Milestone 5: Developer Portal Frontend
|
||||
|
||||
**Status**: Planning
|
||||
**Status**: Decided
|
||||
**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
|
||||
@@ -246,136 +286,224 @@ Approach: npm package
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
## Key Features (htmx Implementation)
|
||||
|
||||
### File Upload
|
||||
### File Upload with Progress
|
||||
|
||||
```typescript
|
||||
// Drag and drop with progress
|
||||
<DropZone
|
||||
accept=".mosis"
|
||||
maxSize={50 * 1024 * 1024}
|
||||
onDrop={handleUpload}
|
||||
onProgress={setProgress}
|
||||
>
|
||||
<UploadIcon />
|
||||
<p>Drop your package here</p>
|
||||
</DropZone>
|
||||
```html
|
||||
<!-- htmx file upload with progress indicator -->
|
||||
<form hx-post="/api/apps/{{.App.ID}}/versions"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#upload-result"
|
||||
hx-indicator="#upload-spinner">
|
||||
|
||||
<div class="dropzone" id="dropzone">
|
||||
<input type="file" name="package" accept=".mosis" required
|
||||
class="hidden" id="file-input">
|
||||
<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
|
||||
// Validate package client-side before upload
|
||||
async function validatePackage(file: File) {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
|
||||
// Check manifest
|
||||
const manifest = await zip.file('manifest.json')?.async('text');
|
||||
if (!manifest) throw new Error('Missing manifest.json');
|
||||
|
||||
const parsed = JSON.parse(manifest);
|
||||
ManifestSchema.parse(parsed);
|
||||
|
||||
// Check required files
|
||||
const entry = parsed.entry;
|
||||
if (!zip.file(entry)) {
|
||||
throw new Error(`Entry file not found: ${entry}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
```html
|
||||
<!-- Server renders this, htmx updates it -->
|
||||
<div id="app-list"
|
||||
hx-get="/partials/apps"
|
||||
hx-trigger="load, newApp from:body"
|
||||
hx-swap="innerHTML">
|
||||
{{range .Apps}}
|
||||
<div class="app-card">
|
||||
<img src="{{.IconURL}}" alt="{{.Name}}">
|
||||
<h3>{{.Name}}</h3>
|
||||
<span class="badge {{.StatusClass}}">{{.Status}}</span>
|
||||
<a href="/apps/{{.ID}}" hx-boost="true">View →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Analytics Dashboard
|
||||
### Form with Validation
|
||||
|
||||
```typescript
|
||||
// Recharts example
|
||||
<LineChart data={downloads}>
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#8884d8" />
|
||||
</LineChart>
|
||||
```html
|
||||
<!-- Create app form with server-side validation -->
|
||||
<form hx-post="/apps" hx-target="#form-result" hx-swap="outerHTML">
|
||||
<div class="form-group">
|
||||
<label for="name">App Name</label>
|
||||
<input type="text" name="name" id="name" required
|
||||
hx-post="/api/validate/name"
|
||||
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
|
||||
// Fetch apps
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => api.get('/apps'),
|
||||
});
|
||||
|
||||
// Create app mutation
|
||||
const createApp = useMutation({
|
||||
mutationFn: (data) => api.post('/apps', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['apps']);
|
||||
},
|
||||
});
|
||||
```go
|
||||
// templates/layouts/base.html
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - Mosis Developer Portal</title>
|
||||
<link href="/static/tailwind.css" rel="stylesheet">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</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
|
||||
// UI state
|
||||
const useStore = create((set) => ({
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
}));
|
||||
```go
|
||||
// templates/pages/dashboard.html
|
||||
{{define "content"}}
|
||||
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<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
|
||||
|
||||
### Login Page
|
||||
### Login Page (Go Template)
|
||||
|
||||
```tsx
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="w-96">
|
||||
<CardHeader>
|
||||
<h1>Sign in to Mosis</h1>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={() => signIn('github')} className="w-full">
|
||||
<GitHubIcon /> Continue with GitHub
|
||||
</Button>
|
||||
<Button onClick={() => signIn('google')} className="w-full">
|
||||
<GoogleIcon /> Continue with Google
|
||||
</Button>
|
||||
<Separator />
|
||||
<form onSubmit={handleEmailLogin}>
|
||||
<Input type="email" placeholder="Email" />
|
||||
<Input type="password" placeholder="Password" />
|
||||
<Button type="submit">Sign in</Button>
|
||||
```html
|
||||
{{define "content"}}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="card w-96 bg-white shadow-lg rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Sign in to Mosis</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<a href="/auth/github" class="btn btn-github w-full flex items-center justify-center gap-2">
|
||||
<svg><!-- GitHub icon --></svg>
|
||||
Continue with GitHub
|
||||
</a>
|
||||
|
||||
<a href="/auth/google" class="btn btn-google w-full flex items-center justify-center gap-2">
|
||||
<svg><!-- Google icon --></svg>
|
||||
Continue with Google
|
||||
</a>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<form hx-post="/auth/login" hx-target="#login-error" class="space-y-4">
|
||||
<input type="email" name="email" placeholder="Email"
|
||||
class="input input-bordered w-full" required>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
### Protected Routes
|
||||
### Auth Middleware (Go)
|
||||
|
||||
```tsx
|
||||
// Middleware (Next.js)
|
||||
export function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get('token');
|
||||
```go
|
||||
// middleware/auth.go
|
||||
func RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := sessionStore.Get(r, "session")
|
||||
if err != nil || session.Values["developer_id"] == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
- [ ] Framework selection
|
||||
- [ ] UI component library selection
|
||||
- [x] Framework selection (htmx + Go Templates)
|
||||
- [x] UI component library selection (Tailwind CSS + Chart.js)
|
||||
- [ ] Design system (colors, typography)
|
||||
- [ ] Page wireframes
|
||||
- [ ] Authentication flow
|
||||
- [x] Page wireframes (see above)
|
||||
- [x] Authentication flow (server sessions + OAuth)
|
||||
- [ ] Dashboard implementation
|
||||
- [ ] App management pages
|
||||
- [ ] Version submission flow
|
||||
@@ -449,10 +577,10 @@ npm install @axe-core/react
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Dark mode support?
|
||||
2. Internationalization (i18n)?
|
||||
3. Custom domain for docs vs integrated?
|
||||
4. Email notifications UI?
|
||||
1. ~~Dark mode support?~~ → Defer to post-MVP (Tailwind makes it easy to add later)
|
||||
2. ~~Internationalization (i18n)?~~ → English only for MVP
|
||||
3. ~~Custom domain for docs vs integrated?~~ → Integrated into same Go server at /docs
|
||||
4. Email notifications UI? → Consider for v1.1
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user