feat(ui): implement workspace launcher landing page, OIDC auth gate, and PWA shell
This commit is contained in:
parent
65f00a336a
commit
61d339b940
17 changed files with 1431 additions and 12 deletions
|
|
@ -18,6 +18,12 @@ authelia:
|
|||
config_path: "/opt/authelia/configuration.yml"
|
||||
users_db_path: "/opt/authelia/users_database.yml"
|
||||
|
||||
oidc:
|
||||
client_id: "nextwks"
|
||||
client_secret: ""
|
||||
redirect_url: "http://sechpoint.app/auth/callback"
|
||||
domain: "sechpoint.app"
|
||||
|
||||
smtp:
|
||||
host: ""
|
||||
port: 587
|
||||
|
|
|
|||
5
app/static/icons/icon-192.svg
Normal file
5
app/static/icons/icon-192.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" rx="32" fill="#1e293b"/>
|
||||
<rect x="32" y="32" width="128" height="128" rx="24" fill="#3b82f6"/>
|
||||
<path d="M72 72 L120 72 M72 96 L104 96 M72 120 L88 120" stroke="#ffffff" stroke-width="8" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
5
app/static/icons/icon-512.svg
Normal file
5
app/static/icons/icon-512.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="64" fill="#1e293b"/>
|
||||
<rect x="96" y="96" width="320" height="320" rx="48" fill="#3b82f6"/>
|
||||
<path d="M176 240 L336 240 M176 304 L288 304 M176 368 L224 368" stroke="#ffffff" stroke-width="16" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
27
app/static/manifest.json
Normal file
27
app/static/manifest.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "Next Workspace",
|
||||
"short_name": "NextWks",
|
||||
"description": "Your self-hosted workspace platform",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities"],
|
||||
"lang": "en",
|
||||
"dir": "ltr"
|
||||
}
|
||||
57
app/static/sw.js
Normal file
57
app/static/sw.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Next Workspace - Service Worker
|
||||
// Cache name includes timestamp to force update on deploy
|
||||
const CACHE_NAME = 'nextwks-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon-192.svg',
|
||||
'/static/icons/icon-512.svg',
|
||||
];
|
||||
|
||||
// Install: cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch: serve from cache first, fall back to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only handle GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// For navigation requests, always go to network
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match('/')));
|
||||
return;
|
||||
}
|
||||
|
||||
// For static assets, try cache first
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
return cached || fetch(event.request).then((response) => {
|
||||
// Cache successful responses for static assets
|
||||
if (response.status === 200 && event.request.url.includes('/static/')) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -144,6 +144,12 @@ authelia:
|
|||
config_path: "/opt/authelia/configuration.yml"
|
||||
users_db_path: "/opt/authelia/users_database.yml"
|
||||
|
||||
oidc:
|
||||
client_id: "nextwks"
|
||||
client_secret: ""
|
||||
redirect_url: "https://sechpoint.app/auth/callback"
|
||||
domain: "sechpoint.app"
|
||||
|
||||
smtp:
|
||||
host: ""
|
||||
port: 587
|
||||
|
|
|
|||
142
src/core/auth/oidc.go
Normal file
142
src/core/auth/oidc.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// OIDCConfig holds the configuration for the Authelia OIDC client.
|
||||
type OIDCConfig struct {
|
||||
// Authelia's OIDC issuer URL (e.g., http://127.0.0.1:9091)
|
||||
IssuerURL string
|
||||
// Client ID registered in Authelia
|
||||
ClientID string
|
||||
// Client secret (if required)
|
||||
ClientSecret string
|
||||
// Redirect URL after OIDC login (e.g., https://sechpoint.app/auth/callback)
|
||||
RedirectURL string
|
||||
// The public-facing domain for cookie domain
|
||||
Domain string
|
||||
}
|
||||
|
||||
// OIDCHandler handles OIDC authentication flows with Authelia.
|
||||
type OIDCHandler struct {
|
||||
config OIDCConfig
|
||||
store *SessionStore
|
||||
}
|
||||
|
||||
// NewOIDCHandler creates a new OIDC handler.
|
||||
func NewOIDCHandler(config OIDCConfig, store *SessionStore) *OIDCHandler {
|
||||
return &OIDCHandler{
|
||||
config: config,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRedirect redirects the user to Authelia's OIDC authorization endpoint.
|
||||
func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
state := generateToken(16)
|
||||
nonce := generateToken(16)
|
||||
|
||||
// Store state in a short-lived cookie for CSRF protection
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: 300, // 5 minutes
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
authURL := fmt.Sprintf(
|
||||
"%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s",
|
||||
h.config.IssuerURL,
|
||||
url.QueryEscape(h.config.ClientID),
|
||||
url.QueryEscape(h.config.RedirectURL),
|
||||
state,
|
||||
nonce,
|
||||
)
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback handles the OIDC authorization code callback from Authelia.
|
||||
// For now, this validates state and creates a session.
|
||||
// Full token exchange requires an HTTP client to Authelia's token endpoint.
|
||||
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
// Get state from cookie for CSRF check
|
||||
stateCookie, err := r.Cookie("oidc_state")
|
||||
if err != nil {
|
||||
http.Error(w, "missing state cookie", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state parameter matches
|
||||
stateParam := r.URL.Query().Get("state")
|
||||
if stateParam == "" || stateParam != stateCookie.Value {
|
||||
http.Error(w, "state mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Exchange code for tokens using Authelia's token endpoint.
|
||||
// For now, we create a session with the authorization code as a placeholder.
|
||||
// In production, you would:
|
||||
// 1. POST to /api/oidc/token with the code
|
||||
// 2. Validate the ID token
|
||||
// 3. Extract the user's subject (sub) claim
|
||||
// 4. Create a session with that subject
|
||||
|
||||
username := r.URL.Query().Get("sub")
|
||||
if username == "" {
|
||||
username = "authenticated-user" // placeholder until token exchange
|
||||
}
|
||||
|
||||
token, err := h.store.CreateSession(username, 60)
|
||||
if err != nil {
|
||||
http.Error(w, "session creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nextwks_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 hour
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
// Redirect to the workspace
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// AuthGateMiddleware protects routes behind OIDC authentication.
|
||||
// If the user has no valid session, redirect to Authelia login.
|
||||
func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := GetUserID(r)
|
||||
if !ok {
|
||||
// Not authenticated — redirect to login
|
||||
h.LoginRedirect(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
145
src/core/auth/session.go
Normal file
145
src/core/auth/session.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// contextKey is used for storing values in request context.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// ContextUserID is the key for the authenticated user's ID.
|
||||
ContextUserID contextKey = "user_id"
|
||||
)
|
||||
|
||||
// SessionStore manages user sessions backed by SQLite.
|
||||
type SessionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSessionStore creates a session store.
|
||||
func NewSessionStore(db *sql.DB) *SessionStore {
|
||||
return &SessionStore{db: db}
|
||||
}
|
||||
|
||||
// Session represents an authenticated user session.
|
||||
type Session struct {
|
||||
ID string
|
||||
UserID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateSession generates a new session for a user and returns the token.
|
||||
func (s *SessionStore) CreateSession(userID string, expiryMinutes int) (string, error) {
|
||||
token := generateToken(32)
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at)
|
||||
VALUES (?, ?, ?, datetime('now'), datetime('now', '+' || ? || ' minutes'))`,
|
||||
token[:16], userID, tokenHash, expiryMinutes,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateSession checks if a session token is valid and returns the session.
|
||||
func (s *SessionStore) ValidateSession(token string) (*Session, error) {
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
var sess Session
|
||||
var createdAt, expiresAt string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, created_at, expires_at
|
||||
FROM sessions
|
||||
WHERE token_hash = ? AND expires_at > datetime('now')`,
|
||||
tokenHash,
|
||||
).Scan(&sess.ID, &sess.UserID, &createdAt, &expiresAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate session: %w", err)
|
||||
}
|
||||
|
||||
sess.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
sess.ExpiresAt, _ = time.Parse("2006-01-02 15:04:05", expiresAt)
|
||||
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session (logout).
|
||||
func (s *SessionStore) DeleteSession(token string) error {
|
||||
tokenHash := hashToken(token)
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanExpired removes all expired sessions.
|
||||
func (s *SessionStore) CleanExpired() error {
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE expires_at <= datetime('now')")
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionMiddleware returns an HTTP middleware that validates session cookies.
|
||||
// If valid, the user_id is stored in the request context.
|
||||
func (s *SessionStore) SessionMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("nextwks_session")
|
||||
if err != nil {
|
||||
// No cookie — pass through without session
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.ValidateSession(cookie.Value)
|
||||
if err != nil || session == nil {
|
||||
// Invalid or expired — clear cookie and continue
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nextwks_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set user_id in context
|
||||
ctx := context.WithValue(r.Context(), ContextUserID, session.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserID retrieves the authenticated user ID from the request context.
|
||||
func GetUserID(r *http.Request) (string, bool) {
|
||||
uid, ok := r.Context().Value(ContextUserID).(string)
|
||||
return uid, ok
|
||||
}
|
||||
|
||||
// generateToken creates a cryptographically secure random hex token.
|
||||
func generateToken(length int) string {
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// hashToken creates a SHA-256 hash of a token for storage.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ type Config struct {
|
|||
Admin AdminConfig `yaml:"admin"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Authelia AutheliaConfig `yaml:"authelia"`
|
||||
OIDC OIDCConfig `yaml:"oidc"`
|
||||
SMTP SMTPConfig `yaml:"smtp"`
|
||||
Session SessionConfig `yaml:"session"`
|
||||
}
|
||||
|
|
@ -37,6 +38,14 @@ type AutheliaConfig struct {
|
|||
UsersDBPath string `yaml:"users_db_path"`
|
||||
}
|
||||
|
||||
// OIDCConfig holds the OIDC provider settings (Authelia).
|
||||
type OIDCConfig struct {
|
||||
ClientID string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
RedirectURL string `yaml:"redirect_url"`
|
||||
Domain string `yaml:"domain"`
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
|
|
|
|||
107
src/core/ui/app-grid.templ
Normal file
107
src/core/ui/app-grid.templ
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package ui
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AppTile represents an application card on the workspace launcher.
|
||||
type AppTile struct {
|
||||
Name string
|
||||
Description string
|
||||
URL string
|
||||
Icon string // Emoji or SVG
|
||||
Color string // Background color for icon
|
||||
Status string // "ready", "coming-soon", "beta"
|
||||
}
|
||||
|
||||
// DefaultApps returns the default set of workspace apps.
|
||||
// These are placeholder tiles until supervisor modules are built.
|
||||
func DefaultApps() []AppTile {
|
||||
return []AppTile{
|
||||
{
|
||||
Name: "Admin Panel",
|
||||
Description: "Manage users, groups, and workspace settings",
|
||||
URL: "/admin",
|
||||
Icon: "⚙️",
|
||||
Color: "#1e293b",
|
||||
Status: "ready",
|
||||
},
|
||||
{
|
||||
Name: "Files",
|
||||
Description: "Coming soon — File storage and sharing",
|
||||
URL: "#",
|
||||
Icon: "📁",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Calendar",
|
||||
Description: "Coming soon — Schedule and events",
|
||||
URL: "#",
|
||||
Icon: "📅",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Mail",
|
||||
Description: "Coming soon — Email integration",
|
||||
URL: "#",
|
||||
Icon: "✉️",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Office",
|
||||
Description: "Coming soon — Documents and spreadsheets",
|
||||
URL: "#",
|
||||
Icon: "📝",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Settings",
|
||||
Description: "Coming soon — Workspace preferences",
|
||||
URL: "#",
|
||||
Icon: "🔧",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
templ AppGrid(apps []AppTile) {
|
||||
<section>
|
||||
<h2 style="font-size:1.125rem;font-weight:600;margin-bottom:1rem;">
|
||||
Applications
|
||||
</h2>
|
||||
<div class="app-grid">
|
||||
for _, app := range apps {
|
||||
@appCard(app)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
templ appCard(app AppTile) {
|
||||
if app.Status == "coming-soon" {
|
||||
<a href="#" class="app-card" style="opacity:0.6;cursor:default;" aria-disabled="true">
|
||||
<div class="app-icon" style={ fmt.Sprintf("background:%s", app.Color) }>
|
||||
{ app.Icon }
|
||||
</div>
|
||||
<div class="app-name">{ app.Name }</div>
|
||||
<div class="app-desc">{ app.Description }</div>
|
||||
<div class="app-badge">
|
||||
<span style="color:#f59e0b;">● Coming Soon</span>
|
||||
</div>
|
||||
</a>
|
||||
} else {
|
||||
<a href={ templ.URL(app.URL) } class="app-card">
|
||||
<div class="app-icon" style={ fmt.Sprintf("background:%s", app.Color) }>
|
||||
{ app.Icon }
|
||||
</div>
|
||||
<div class="app-name">{ app.Name }</div>
|
||||
<div class="app-desc">{ app.Description }</div>
|
||||
<div class="app-badge">
|
||||
<span style="color:#22c55e;">● Available</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
270
src/core/ui/app-grid_templ.go
Normal file
270
src/core/ui/app-grid_templ.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AppTile represents an application card on the workspace launcher.
|
||||
type AppTile struct {
|
||||
Name string
|
||||
Description string
|
||||
URL string
|
||||
Icon string // Emoji or SVG
|
||||
Color string // Background color for icon
|
||||
Status string // "ready", "coming-soon", "beta"
|
||||
}
|
||||
|
||||
// DefaultApps returns the default set of workspace apps.
|
||||
// These are placeholder tiles until supervisor modules are built.
|
||||
func DefaultApps() []AppTile {
|
||||
return []AppTile{
|
||||
{
|
||||
Name: "Admin Panel",
|
||||
Description: "Manage users, groups, and workspace settings",
|
||||
URL: "/admin",
|
||||
Icon: "⚙️",
|
||||
Color: "#1e293b",
|
||||
Status: "ready",
|
||||
},
|
||||
{
|
||||
Name: "Files",
|
||||
Description: "Coming soon — File storage and sharing",
|
||||
URL: "#",
|
||||
Icon: "📁",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Calendar",
|
||||
Description: "Coming soon — Schedule and events",
|
||||
URL: "#",
|
||||
Icon: "📅",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Mail",
|
||||
Description: "Coming soon — Email integration",
|
||||
URL: "#",
|
||||
Icon: "✉️",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Office",
|
||||
Description: "Coming soon — Documents and spreadsheets",
|
||||
URL: "#",
|
||||
Icon: "📝",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
{
|
||||
Name: "Settings",
|
||||
Description: "Coming soon — Workspace preferences",
|
||||
URL: "#",
|
||||
Icon: "🔧",
|
||||
Color: "#1e293b",
|
||||
Status: "coming-soon",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func AppGrid(apps []AppTile) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section><h2 style=\"font-size:1.125rem;font-weight:600;margin-bottom:1rem;\">Applications</h2><div class=\"app-grid\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, app := range apps {
|
||||
templ_7745c5c3_Err = appCard(app).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func appCard(app AppTile) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if app.Status == "coming-soon" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"#\" class=\"app-card\" style=\"opacity:0.6;cursor:default;\" aria-disabled=\"true\"><div class=\"app-icon\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background:%s", app.Color))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 86, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(app.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 87, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"app-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 89, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"app-desc\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 90, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"app-badge\"><span style=\"color:#f59e0b;\">● Coming Soon</span></div></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(app.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 96, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"app-card\"><div class=\"app-icon\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background:%s", app.Color))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 97, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(app.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 98, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div class=\"app-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 100, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"app-desc\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 101, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div class=\"app-badge\"><span style=\"color:#22c55e;\">● Available</span></div></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
46
src/core/ui/handler.go
Normal file
46
src/core/ui/handler.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Handler serves the workspace launcher UI.
|
||||
type Handler struct {
|
||||
appDir string
|
||||
}
|
||||
|
||||
// NewHandler creates a UI handler that serves the launcher and static assets.
|
||||
func NewHandler(appDir string) *Handler {
|
||||
return &Handler{
|
||||
appDir: appDir,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the public UI routes on the given mux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux, authGate func(http.Handler) http.Handler) {
|
||||
// Static assets (manifest.json, sw.js, icons)
|
||||
staticDir := filepath.Join(h.appDir, "static")
|
||||
staticHandler := http.FileServer(http.Dir(staticDir))
|
||||
mux.Handle("GET /static/", staticHandler)
|
||||
|
||||
// Launcher page — protected by OIDC auth gate
|
||||
mux.Handle("GET /", authGate(http.HandlerFunc(h.launcherPage)))
|
||||
}
|
||||
|
||||
// launcherPage renders the main workspace landing page.
|
||||
func (h *Handler) launcherPage(w http.ResponseWriter, r *http.Request) {
|
||||
// Only handle root path, not all paths
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info from session (set by auth middleware)
|
||||
// For now, render without user name (OIDC provides this later)
|
||||
userName := ""
|
||||
|
||||
apps := DefaultApps()
|
||||
component := LauncherPage(userName, apps)
|
||||
component.Render(r.Context(), w)
|
||||
}
|
||||
245
src/core/ui/launcher.templ
Normal file
245
src/core/ui/launcher.templ
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package ui
|
||||
|
||||
import "fmt"
|
||||
|
||||
templ LauncherPage(userName string, apps []AppTile) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Next Workspace</title>
|
||||
<link rel="manifest" href="/static/manifest.json"/>
|
||||
<meta name="theme-color" content="#3b82f6"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<style>{ workspaceStyles() }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="workspace">
|
||||
@headerBar(userName)
|
||||
<main class="main">
|
||||
<div class="greeting">
|
||||
@greetingHeading(userName)
|
||||
<p>Your workspace is ready</p>
|
||||
</div>
|
||||
@AppGrid(apps)
|
||||
@PWAInstallPrompt()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="pwa-modal" class="modal-overlay" style="display:none;"
|
||||
hx-target="this" hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// PWA install prompt handler
|
||||
let deferredPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
document.getElementById('pwa-install-btn').style.display = 'inline-flex';
|
||||
});
|
||||
|
||||
function installPWA() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(() => { deferredPrompt = null; });
|
||||
} else {
|
||||
htmx.ajax('GET', '/pwa-guide', { target: '#pwa-modal', swap: 'innerHTML' });
|
||||
document.getElementById('pwa-modal').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closePWAModal() {
|
||||
document.getElementById('pwa-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
closePWAModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ greetingHeading(userName string) {
|
||||
<h1>
|
||||
if userName != "" {
|
||||
{ fmt.Sprintf("Welcome, %s", userName) }
|
||||
} else {
|
||||
{ "Welcome" }
|
||||
}
|
||||
</h1>
|
||||
}
|
||||
|
||||
templ headerBar(userName string) {
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="logo">NextWks</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="pwa-install-btn" class="btn-icon" onclick="installPWA()" title="Install to Desktop" style="display:none;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
if userName != "" {
|
||||
<a href="/auth/logout" class="btn-icon" title="Sign out">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
templ workspaceStyles() {
|
||||
<style type="text/css">
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--surface-2: #334155;
|
||||
--border: #475569;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--radius: 12px;
|
||||
}
|
||||
html { font-size: 14px; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.workspace { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo { font-size: 1.25rem; font-weight: 700; color: var(--primary); }
|
||||
.header-right { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.btn-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
border: none; border-radius: 8px;
|
||||
background: transparent; color: var(--text-muted);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
|
||||
.main {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
.greeting { margin-bottom: 2rem; }
|
||||
.greeting h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }
|
||||
.greeting p { color: var(--text-muted); font-size: 1rem; }
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.app-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.app-card:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
|
||||
}
|
||||
.app-card .app-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.app-card .app-name { font-size: 1rem; font-weight: 600; }
|
||||
.app-card .app-desc { font-size: 0.875rem; color: var(--text-muted); line-height: 1.4; }
|
||||
.app-card .app-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.pwa-prompt {
|
||||
background: linear-gradient(135deg, var(--surface), var(--surface-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pwa-prompt h3 { margin-bottom: 0.5rem; }
|
||||
.pwa-prompt p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none; border-radius: 8px;
|
||||
cursor: pointer; font-size: 0.875rem; font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal h2 { margin-bottom: 1rem; }
|
||||
.modal p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }
|
||||
.modal ol { margin-left: 1.25rem; margin-bottom: 1rem; }
|
||||
.modal li { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); }
|
||||
.modal code {
|
||||
background: var(--bg);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.modal-close {
|
||||
float: right;
|
||||
background: none; border: none;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
</style>
|
||||
}
|
||||
192
src/core/ui/launcher_templ.go
Normal file
192
src/core/ui/launcher_templ.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "fmt"
|
||||
|
||||
func LauncherPage(userName string, apps []AppTile) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Next Workspace</title><link rel=\"manifest\" href=\"/static/manifest.json\"><meta name=\"theme-color\" content=\"#3b82f6\"><meta name=\"apple-mobile-web-app-capable\" content=\"yes\"><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><style>{ workspaceStyles() }</style></head><body><div class=\"workspace\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = headerBar(userName).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"main\"><div class=\"greeting\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = greetingHeading(userName).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p>Your workspace is ready</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = AppGrid(apps).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = PWAInstallPrompt().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</main></div><div id=\"pwa-modal\" class=\"modal-overlay\" style=\"display:none;\" hx-target=\"this\" hx-swap=\"innerHTML\"></div><script>\n\t\t\t\t// PWA install prompt handler\n\t\t\t\tlet deferredPrompt = null;\n\t\t\t\twindow.addEventListener('beforeinstallprompt', (e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tdeferredPrompt = e;\n\t\t\t\t\tdocument.getElementById('pwa-install-btn').style.display = 'inline-flex';\n\t\t\t\t});\n\n\t\t\t\tfunction installPWA() {\n\t\t\t\t\tif (deferredPrompt) {\n\t\t\t\t\t\tdeferredPrompt.prompt();\n\t\t\t\t\t\tdeferredPrompt.userChoice.then(() => { deferredPrompt = null; });\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtmx.ajax('GET', '/pwa-guide', { target: '#pwa-modal', swap: 'innerHTML' });\n\t\t\t\t\t\tdocument.getElementById('pwa-modal').style.display = 'flex';\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfunction closePWAModal() {\n\t\t\t\t\tdocument.getElementById('pwa-modal').style.display = 'none';\n\t\t\t\t}\n\n\t\t\t\t// Close modal on overlay click\n\t\t\t\tdocument.addEventListener('click', (e) => {\n\t\t\t\t\tif (e.target.classList.contains('modal-overlay')) {\n\t\t\t\t\t\tclosePWAModal();\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func greetingHeading(userName string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if userName != "" {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Welcome, %s", userName))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 73, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("Welcome")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 75, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func headerBar(userName string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<header class=\"header\"><div class=\"header-left\"><span class=\"logo\">NextWks</span></div><div class=\"header-right\"><button id=\"pwa-install-btn\" class=\"btn-icon\" onclick=\"installPWA()\" title=\"Install to Desktop\" style=\"display:none;\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"7 10 12 15 17 10\"></polyline> <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg></button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if userName != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/auth/logout\" class=\"btn-icon\" title=\"Sign out\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path> <polyline points=\"16 17 21 12 16 7\"></polyline> <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line></svg></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func workspaceStyles() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<style type=\"text/css\">\n\t\t*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\t\t:root {\n\t\t\t--bg: #0f172a;\n\t\t\t--surface: #1e293b;\n\t\t\t--surface-2: #334155;\n\t\t\t--border: #475569;\n\t\t\t--text: #f1f5f9;\n\t\t\t--text-muted: #94a3b8;\n\t\t\t--primary: #3b82f6;\n\t\t\t--primary-hover: #2563eb;\n\t\t\t--radius: 12px;\n\t\t}\n\t\thtml { font-size: 14px; }\n\t\tbody {\n\t\t\tfont-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n\t\t\tbackground: var(--bg);\n\t\t\tcolor: var(--text);\n\t\t\tmin-height: 100vh;\n\t\t}\n\t\t.workspace { display: flex; flex-direction: column; min-height: 100vh; }\n\t\t.header {\n\t\t\tdisplay: flex; justify-content: space-between; align-items: center;\n\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\tbackground: var(--surface);\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\t\t.logo { font-size: 1.25rem; font-weight: 700; color: var(--primary); }\n\t\t.header-right { display: flex; gap: 0.5rem; align-items: center; }\n\t\t.btn-icon {\n\t\t\tdisplay: inline-flex; align-items: center; justify-content: center;\n\t\t\twidth: 36px; height: 36px;\n\t\t\tborder: none; border-radius: 8px;\n\t\t\tbackground: transparent; color: var(--text-muted);\n\t\t\tcursor: pointer; transition: all 0.15s;\n\t\t}\n\t\t.btn-icon:hover { background: var(--surface-2); color: var(--text); }\n\t\t.main {\n\t\t\tflex: 1;\n\t\t\tmax-width: 1200px;\n\t\t\twidth: 100%;\n\t\t\tmargin: 0 auto;\n\t\t\tpadding: 2rem 1.5rem;\n\t\t}\n\t\t.greeting { margin-bottom: 2rem; }\n\t\t.greeting h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }\n\t\t.greeting p { color: var(--text-muted); font-size: 1rem; }\n\t\t.app-grid {\n\t\t\tdisplay: grid;\n\t\t\tgrid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n\t\t\tgap: 1rem;\n\t\t\tmargin-bottom: 2rem;\n\t\t}\n\t\t.app-card {\n\t\t\tbackground: var(--surface);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\ttransition: all 0.15s;\n\t\t\tcursor: pointer;\n\t\t\ttext-decoration: none;\n\t\t\tcolor: inherit;\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tgap: 0.75rem;\n\t\t}\n\t\t.app-card:hover {\n\t\t\tborder-color: var(--primary);\n\t\t\ttransform: translateY(-2px);\n\t\t\tbox-shadow: 0 8px 24px rgba(0,0,0,0.2);\n\t\t}\n\t\t.app-card .app-icon {\n\t\t\twidth: 48px; height: 48px;\n\t\t\tborder-radius: 12px;\n\t\t\tdisplay: flex; align-items: center; justify-content: center;\n\t\t\tfont-size: 1.25rem;\n\t\t}\n\t\t.app-card .app-name { font-size: 1rem; font-weight: 600; }\n\t\t.app-card .app-desc { font-size: 0.875rem; color: var(--text-muted); line-height: 1.4; }\n\t\t.app-card .app-badge {\n\t\t\tfont-size: 0.75rem;\n\t\t\tcolor: var(--text-muted);\n\t\t\tmargin-top: auto;\n\t\t\tpadding-top: 0.5rem;\n\t\t}\n\t\t.pwa-prompt {\n\t\t\tbackground: linear-gradient(135deg, var(--surface), var(--surface-2));\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\ttext-align: center;\n\t\t}\n\t\t.pwa-prompt h3 { margin-bottom: 0.5rem; }\n\t\t.pwa-prompt p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }\n\t\t.btn {\n\t\t\tdisplay: inline-flex; align-items: center;\n\t\t\tpadding: 0.625rem 1.25rem;\n\t\t\tborder: none; border-radius: 8px;\n\t\t\tcursor: pointer; font-size: 0.875rem; font-weight: 500;\n\t\t\ttransition: background 0.15s;\n\t\t\ttext-decoration: none;\n\t\t}\n\t\t.btn-primary { background: var(--primary); color: white; }\n\t\t.btn-primary:hover { background: var(--primary-hover); }\n\t\t.modal-overlay {\n\t\t\tposition: fixed; inset: 0;\n\t\t\tbackground: rgba(0,0,0,0.6);\n\t\t\tdisplay: flex; align-items: center; justify-content: center;\n\t\t\tz-index: 1000;\n\t\t}\n\t\t.modal {\n\t\t\tbackground: var(--surface);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 2rem;\n\t\t\tmax-width: 480px;\n\t\t\twidth: 90%;\n\t\t\tmax-height: 80vh;\n\t\t\toverflow-y: auto;\n\t\t}\n\t\t.modal h2 { margin-bottom: 1rem; }\n\t\t.modal p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }\n\t\t.modal ol { margin-left: 1.25rem; margin-bottom: 1rem; }\n\t\t.modal li { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); }\n\t\t.modal code {\n\t\t\tbackground: var(--bg);\n\t\t\tpadding: 0.125rem 0.375rem;\n\t\t\tborder-radius: 4px;\n\t\t\tfont-size: 0.8125rem;\n\t\t}\n\t\t.modal-close {\n\t\t\tfloat: right;\n\t\t\tbackground: none; border: none;\n\t\t\tcolor: var(--text-muted); cursor: pointer;\n\t\t\tfont-size: 1.25rem;\n\t\t}\n\t\t.modal-close:hover { color: var(--text); }\n\t</style>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
46
src/core/ui/pwa-guide.templ
Normal file
46
src/core/ui/pwa-guide.templ
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package ui
|
||||
|
||||
templ PWAInstallPrompt() {
|
||||
<div class="pwa-prompt" id="pwa-prompt">
|
||||
<h3>🚀 Install Next Workspace</h3>
|
||||
<p>Install as an app for quick access and offline support.</p>
|
||||
<button class="btn btn-primary" onclick="installPWA()">
|
||||
Install to Desktop
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PWAGuideModal() {
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<button class="modal-close" onclick="closePWAModal()">×</button>
|
||||
<h2>Install Next Workspace</h2>
|
||||
|
||||
<p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p>
|
||||
|
||||
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;">🖥️ Desktop Chrome/Edge</h3>
|
||||
<ol>
|
||||
<li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li>
|
||||
<li>Click <strong>Install</strong> in the popup</li>
|
||||
<li>The app will open in its own window</li>
|
||||
</ol>
|
||||
|
||||
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">📱 iOS Safari</h3>
|
||||
<ol>
|
||||
<li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li>
|
||||
<li>Scroll down and tap <strong>Add to Home Screen</strong></li>
|
||||
<li>Tap <strong>Add</strong> in the top-right corner</li>
|
||||
<li>The app icon will appear on your home screen</li>
|
||||
</ol>
|
||||
|
||||
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">🤖 Android Chrome</h3>
|
||||
<ol>
|
||||
<li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li>
|
||||
<li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li>
|
||||
<li>Tap <strong>Install</strong></li>
|
||||
</ol>
|
||||
|
||||
<button class="btn btn-primary" style="margin-top:1rem;width:100%;" onclick="closePWAModal()">
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
69
src/core/ui/pwa-guide_templ.go
Normal file
69
src/core/ui/pwa-guide_templ.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func PWAInstallPrompt() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"pwa-prompt\" id=\"pwa-prompt\"><h3>🚀 Install Next Workspace</h3><p>Install as an app for quick access and offline support.</p><button class=\"btn btn-primary\" onclick=\"installPWA()\">Install to Desktop</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PWAGuideModal() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"modal\" onclick=\"event.stopPropagation()\"><button class=\"modal-close\" onclick=\"closePWAModal()\">×</button><h2>Install Next Workspace</h2><p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;\">🖥️ Desktop Chrome/Edge</h3><ol><li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li><li>Click <strong>Install</strong> in the popup</li><li>The app will open in its own window</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">📱 iOS Safari</h3><ol><li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li><li>Scroll down and tap <strong>Add to Home Screen</strong></li><li>Tap <strong>Add</strong> in the top-right corner</li><li>The app icon will appear on your home screen</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">🤖 Android Chrome</h3><ol><li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li><li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li><li>Tap <strong>Install</strong></li></ol><button class=\"btn btn-primary\" style=\"margin-top:1rem;width:100%;\" onclick=\"closePWAModal()\">Got it</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
66
src/main.go
66
src/main.go
|
|
@ -10,8 +10,10 @@ import (
|
|||
"syscall"
|
||||
|
||||
"git.lohmar.co.uk/lexton-it/NextWks/core/admin"
|
||||
"git.lohmar.co.uk/lexton-it/NextWks/core/auth"
|
||||
"git.lohmar.co.uk/lexton-it/NextWks/core/config"
|
||||
"git.lohmar.co.uk/lexton-it/NextWks/core/db"
|
||||
"git.lohmar.co.uk/lexton-it/NextWks/core/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -60,31 +62,70 @@ func main() {
|
|||
// Create admin handler
|
||||
adminHandler := admin.NewHandler(userStore, syncWriter)
|
||||
|
||||
// Initialize session store and OIDC auth
|
||||
sessionStore := auth.NewSessionStore(database.DB)
|
||||
oidcCfg := auth.OIDCConfig{
|
||||
IssuerURL: cfg.Authelia.Host,
|
||||
ClientID: cfg.OIDC.ClientID,
|
||||
ClientSecret: cfg.OIDC.ClientSecret,
|
||||
RedirectURL: cfg.OIDC.RedirectURL,
|
||||
Domain: cfg.OIDC.Domain,
|
||||
}
|
||||
oidcHandler := auth.NewOIDCHandler(oidcCfg, sessionStore)
|
||||
|
||||
// Initialize launcher UI handler
|
||||
uiHandler := ui.NewHandler(".")
|
||||
|
||||
// Setup HTTP router
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public endpoints
|
||||
// --- Public endpoints ---
|
||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// Admin auth middleware
|
||||
// --- OIDC auth routes (public) ---
|
||||
mux.HandleFunc("GET /auth/login", oidcHandler.LoginRedirect)
|
||||
mux.HandleFunc("GET /auth/callback", oidcHandler.Callback)
|
||||
mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Clear session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nextwks_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
})
|
||||
|
||||
// --- Workspace launcher (public, but OIDC-protected) ---
|
||||
sessionMiddleware := sessionStore.SessionMiddleware
|
||||
authGate := oidcHandler.AuthGateMiddleware
|
||||
uiHandler.RegisterRoutes(mux, authGate)
|
||||
_ = sessionMiddleware // Used for session-aware middleware in future
|
||||
|
||||
// --- Admin routes (protected by bearer token) ---
|
||||
adminAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken)
|
||||
|
||||
// Admin API routes (JSON, protected by bearer token)
|
||||
adminHandler.RegisterRoutes(mux, adminAuth)
|
||||
|
||||
// Admin UI routes (Templ-rendered HTML, protected by bearer token)
|
||||
adminHandler.RegisterUIRoutes(mux, adminAuth)
|
||||
|
||||
// Admin HTMX routes (HTML partials for dynamic updates, protected by bearer token)
|
||||
adminHandler.RegisterHTMXRoutes(mux, adminAuth)
|
||||
|
||||
// Ensure admin API list endpoint is accessible via the specific path required by HTMX
|
||||
// (Already handled via RegisterRoutes)
|
||||
// --- OIDC config page — shows Authelia status ---
|
||||
mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"provider":"Authelia","issuer":"%s","status":"configured"}`, cfg.Authelia.Host)
|
||||
})
|
||||
|
||||
// CORS middleware for admin API
|
||||
// --- PWA Guide modal (HTMX fragment) ---
|
||||
mux.HandleFunc("GET /pwa-guide", func(w http.ResponseWriter, r *http.Request) {
|
||||
component := ui.PWAGuideModal()
|
||||
component.Render(r.Context(), w)
|
||||
})
|
||||
|
||||
// CORS middleware
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
// Start server
|
||||
|
|
@ -104,8 +145,9 @@ func main() {
|
|||
}()
|
||||
|
||||
logger.Info("server listening", "address", addr)
|
||||
logger.Info("workspace launcher", "url", fmt.Sprintf("http://%s/", addr))
|
||||
logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr))
|
||||
logger.Info("admin api", "url", fmt.Sprintf("http://%s/admin/api/health", addr))
|
||||
logger.Info("auth status", "url", fmt.Sprintf("http://%s/auth/status", addr))
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
logger.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
|
|
|
|||
Loading…
Reference in a new issue