feat(ui): Google Workspace-style redesign, role-based admin, long-press PWA

This commit is contained in:
Claus Lohmar 2026-06-14 17:48:00 +00:00
parent cb4eb38289
commit 2579715cbc
7 changed files with 598 additions and 507 deletions

View file

@ -15,18 +15,19 @@ import (
type contextKey string
const (
// ContextUserID is the key for the authenticated user's ID.
ContextUserID contextKey = "user_id"
ContextRole contextKey = "role"
)
// SessionStore manages user sessions backed by SQLite.
type SessionStore struct {
db *sql.DB
roleDB *sql.DB // Optional: same DB, used for role lookups
}
// NewSessionStore creates a session store.
func NewSessionStore(db *sql.DB) *SessionStore {
return &SessionStore{db: db}
return &SessionStore{db: db, roleDB: db}
}
// Session represents an authenticated user session.
@ -120,7 +121,15 @@ func (s *SessionStore) SessionMiddleware(next http.Handler) http.Handler {
}
// Set user_id in context
// Set user_id and role in context
ctx := context.WithValue(r.Context(), ContextUserID, session.UserID)
// Look up role from database
var role string
s.roleDB.QueryRow("SELECT role FROM users WHERE username = ?", session.UserID).Scan(&role)
if role == "" {
role = "user"
}
ctx = context.WithValue(ctx, ContextRole, role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View file

@ -1,107 +1,107 @@
package ui
import "fmt"
// AppTile represents an application card on the workspace launcher.
// AppTile represents an app 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"
Icon string // SVG inline
Color string // Background color
Status string // "ready" or "coming-soon"
AdminOnly bool // Only visible to admin role
}
// DefaultApps returns the default set of workspace apps.
// These are placeholder tiles until supervisor modules are built.
func DefaultApps() []AppTile {
return []AppTile{
// DefaultApps returns apps filtered by user role.
func DefaultApps(role string) []AppTile {
all := []AppTile{
{
Name: "Admin Panel",
Description: "Manage users, groups, and workspace settings",
URL: "/admin",
Icon: "⚙️",
Color: "#1e293b",
Status: "ready",
Name: "Admin Panel", Description: "Users, groups & settings",
URL: "/admin", Color: "#9333ea",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
Status: "ready", AdminOnly: true,
},
{
Name: "Files",
Description: "Coming soon — File storage and sharing",
URL: "#",
Icon: "📁",
Color: "#1e293b",
Name: "Files", Description: "Storage & sharing",
URL: "#", Color: "#2563eb",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
Status: "coming-soon",
},
{
Name: "Calendar",
Description: "Coming soon — Schedule and events",
URL: "#",
Icon: "📅",
Color: "#1e293b",
Name: "Mail", Description: "Email client",
URL: "#", Color: "#dc2626",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
Status: "coming-soon",
},
{
Name: "Mail",
Description: "Coming soon — Email integration",
URL: "#",
Icon: "✉️",
Color: "#1e293b",
Name: "Calendar", Description: "Schedule & events",
URL: "#", Color: "#059669",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
Status: "coming-soon",
},
{
Name: "Office",
Description: "Coming soon — Documents and spreadsheets",
URL: "#",
Icon: "📝",
Color: "#1e293b",
Name: "Office", Description: "Documents & sheets",
URL: "#", Color: "#0891b2",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
Status: "coming-soon",
},
{
Name: "Settings",
Description: "Coming soon — Workspace preferences",
URL: "#",
Icon: "🔧",
Color: "#1e293b",
Name: "Contacts", Description: "People & directory",
URL: "#", Color: "#d97706",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
Status: "coming-soon",
},
{
Name: "Tasks", Description: "Project management",
URL: "#", Color: "#7c3aed",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`,
Status: "coming-soon",
},
{
Name: "Chat", Description: "Team messaging",
URL: "#", Color: "#db2777",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
Status: "coming-soon",
},
}
// Filter by role
filtered := make([]AppTile, 0, len(all))
for _, a := range all {
if a.AdminOnly && role != "admin" {
continue
}
filtered = append(filtered, a)
}
return filtered
}
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)
for _, a := range apps {
@appTile(a)
}
</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 }
templ appTile(a AppTile) {
if a.Status == "coming-soon" {
<div class="app-card disabled"
oncontextmenu="return false"
ontouchstart="startLongPress(event)" ontouchend="cancelLongPress()"
onmousedown="startLongPress(event)" onmouseup="cancelLongPress()">
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
<div class="app-name">{ a.Name }</div>
<div class="app-badge">Coming Soon</div>
</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 class="app-card"
onclick="openApp('{ a.URL }')"
oncontextmenu="return false"
ontouchstart="startLongPress(event, '{ a.URL }')" ontouchend="cancelLongPress()"
onmousedown="startLongPress(event, '{ a.URL }')" onmouseup="cancelLongPress()">
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
<div class="app-name">{ a.Name }</div>
<div class="app-badge">Available</div>
</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>
}
}

View file

@ -8,71 +8,79 @@ package ui
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.
// AppTile represents an app 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"
Icon string // SVG inline
Color string // Background color
Status string // "ready" or "coming-soon"
AdminOnly bool // Only visible to admin role
}
// DefaultApps returns the default set of workspace apps.
// These are placeholder tiles until supervisor modules are built.
func DefaultApps() []AppTile {
return []AppTile{
// DefaultApps returns apps filtered by user role.
func DefaultApps(role string) []AppTile {
all := []AppTile{
{
Name: "Admin Panel",
Description: "Manage users, groups, and workspace settings",
URL: "/admin",
Icon: "⚙️",
Color: "#1e293b",
Status: "ready",
Name: "Admin Panel", Description: "Users, groups & settings",
URL: "/admin", Color: "#9333ea",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
Status: "ready", AdminOnly: true,
},
{
Name: "Files",
Description: "Coming soon — File storage and sharing",
URL: "#",
Icon: "📁",
Color: "#1e293b",
Name: "Files", Description: "Storage & sharing",
URL: "#", Color: "#2563eb",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
Status: "coming-soon",
},
{
Name: "Calendar",
Description: "Coming soon — Schedule and events",
URL: "#",
Icon: "📅",
Color: "#1e293b",
Name: "Mail", Description: "Email client",
URL: "#", Color: "#dc2626",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
Status: "coming-soon",
},
{
Name: "Mail",
Description: "Coming soon — Email integration",
URL: "#",
Icon: "✉️",
Color: "#1e293b",
Name: "Calendar", Description: "Schedule & events",
URL: "#", Color: "#059669",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
Status: "coming-soon",
},
{
Name: "Office",
Description: "Coming soon — Documents and spreadsheets",
URL: "#",
Icon: "📝",
Color: "#1e293b",
Name: "Office", Description: "Documents & sheets",
URL: "#", Color: "#0891b2",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
Status: "coming-soon",
},
{
Name: "Settings",
Description: "Coming soon — Workspace preferences",
URL: "#",
Icon: "🔧",
Color: "#1e293b",
Name: "Contacts", Description: "People & directory",
URL: "#", Color: "#d97706",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
Status: "coming-soon",
},
{
Name: "Tasks", Description: "Project management",
URL: "#", Color: "#7c3aed",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`,
Status: "coming-soon",
},
{
Name: "Chat", Description: "Team messaging",
URL: "#", Color: "#db2777",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
Status: "coming-soon",
},
}
// Filter by role
filtered := make([]AppTile, 0, len(all))
for _, a := range all {
if a.AdminOnly && role != "admin" {
continue
}
filtered = append(filtered, a)
}
return filtered
}
func AppGrid(apps []AppTile) templ.Component {
@ -96,17 +104,17 @@ func AppGrid(apps []AppTile) templ.Component {
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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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)
for _, a := range apps {
templ_7745c5c3_Err = appTile(a).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>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -114,7 +122,7 @@ func AppGrid(apps []AppTile) templ.Component {
})
}
func appCard(app AppTile) templ.Component {
func appTile(a 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 {
@ -135,15 +143,15 @@ func appCard(app AppTile) templ.Component {
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 a.Status == "coming-soon" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-card disabled\" oncontextmenu=\"return false\" ontouchstart=\"startLongPress(event)\" ontouchend=\"cancelLongPress()\" onmousedown=\"startLongPress(event)\" onmouseup=\"cancelLongPress()\"><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))
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + a.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 86, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 92, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@ -153,12 +161,7 @@ func appCard(app AppTile) templ.Component {
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))
templ_7745c5c3_Err = templ.Raw(a.Icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -166,99 +169,55 @@ func appCard(app AppTile) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 89, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 93, Col: 33}
}
_, 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, "</div><div class=\"app-badge\">Coming Soon</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"app-card\" onclick=\"openApp('{ a.URL }')\" oncontextmenu=\"return false\" ontouchstart=\"startLongPress(event, '{ a.URL }')\" ontouchend=\"cancelLongPress()\" onmousedown=\"startLongPress(event, '{ a.URL }')\" onmouseup=\"cancelLongPress()\"><div class=\"app-icon\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + a.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 102, Col: 56}
}
_, 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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(a.Icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><div class=\"app-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 90, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 103, Col: 33}
}
_, 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>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><div class=\"app-badge\">Available</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -3,6 +3,8 @@ package ui
import (
"net/http"
"path/filepath"
"git.lohmar.co.uk/lexton-it/NextWks/core/auth"
)
// Handler serves the workspace launcher UI.
@ -12,35 +14,34 @@ type Handler struct {
// NewHandler creates a UI handler that serves the launcher and static assets.
func NewHandler(appDir string) *Handler {
return &Handler{
appDir: appDir,
}
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/", http.StripPrefix("/static", staticHandler))
// Launcher page — protected by OIDC auth gate
mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))))
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 := ""
userID, _ := auth.GetUserID(r)
role := getUserRole(r)
apps := DefaultApps(role)
apps := DefaultApps()
component := LauncherPage(userName, apps)
component := LauncherPage(userID, role, apps)
component.Render(r.Context(), w)
}
// getUserRole extracts the user's role from the request context or session.
func getUserRole(r *http.Request) string {
if role, ok := r.Context().Value(auth.ContextRole).(string); ok {
return role
}
return "user"
}

View file

@ -1,245 +1,269 @@
package ui
import "fmt"
templ LauncherPage(userName string, apps []AppTile) {
templ LauncherPage(userName string, role string, apps []AppTile) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Next Workspace</title>
<link rel="manifest" href="/static/manifest.json"/>
<meta name="theme-color" content="#3b82f6"/>
<meta name="theme-color" content="#1a1a2e"/>
<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>
<style>{ workspaceCSS() }</style>
</head>
<body>
<div class="workspace">
@headerBar(userName)
<main class="main">
<div class="greeting">
@greetingHeading(userName)
<p>Your workspace is ready</p>
</div>
<div class="shell">
@topBar(userName, role)
<main class="content">
@greetingSection(userName)
@AppGrid(apps)
@PWAInstallPrompt()
</main>
</div>
<div id="pwa-modal" class="modal-overlay" style="display:none;"
hx-target="this" hx-swap="innerHTML">
<!-- PWA Modal -->
<div id="pwa-overlay" class="overlay" style="display:none" onclick="closePWA()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-head">
<h2>Install Next Workspace</h2>
<button class="close-btn" onclick="closePWA()">&times;</button>
</div>
<div class="modal-body" id="pwa-content">
@pwaInstructions()
</div>
</div>
</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>
<script>{ launcherJS() }</script>
</body>
</html>
}
templ greetingHeading(userName string) {
<h1>
if userName != "" {
{ fmt.Sprintf("Welcome, %s", userName) }
} else {
{ "Welcome" }
}
</h1>
templ greetingSection(userName string) {
<div class="greeting">
<div class="avatar">{ initials(userName) }</div>
<div class="greeting-text">
<h1>Good { timeOfDay() }, { userName }</h1>
<p>Your workspace is ready</p>
</div>
</div>
}
templ headerBar(userName string) {
<header class="header">
<div class="header-left">
<span class="logo">NextWks</span>
templ topBar(userName string, role string) {
<header class="topbar">
<div class="topbar-left">
<div class="logo-icon">N</div>
<span class="logo-text">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>
<div class="topbar-right">
<button class="icon-btn" onclick="showPWA()" title="Install app">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-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 class="user-menu">
<div class="avatar sm">{ initials(userName) }</div>
</div>
</div>
</header>
}
templ workspaceStyles() {
templ workspaceCSS() {
<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;
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117; --surface:#161b22; --surface2:#21262d; --surface3:#30363d;
--border:#30363d; --text:#e6edf3; --text2:#8b949e; --text3:#6e7681;
--blue:#58a6ff; --green:#3fb950; --orange:#d2991d; --red:#f85149;
--purple:#a371f7; --pink:#db61a2; --teal:#39d353;
--radius:16px; --radius-sm:10px
}
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
html{font-size:15px}
body{
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;
background:var(--bg); color:var(--text); min-height:100vh; min-height:100dvh;
-webkit-tap-highlight-color:transparent; -webkit-user-select:none; user-select:none
}
.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);
.shell{display:flex;flex-direction:column;min-height:100vh;min-height:100dvh}
.topbar{
display:flex; justify-content:space-between; align-items:center;
padding:12px 16px; background:var(--surface); border-bottom:1px solid var(--border);
position:sticky; top:0; z-index:100
}
.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;
.topbar-left{display:flex;align-items:center;gap:10px}
.logo-icon{
width:32px;height:32px;border-radius:8px;background:var(--blue);
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:16px;color:#fff
}
.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;
.logo-text{font-weight:600;font-size:18px;color:var(--text)}
.topbar-right{display:flex;align-items:center;gap:8px}
.icon-btn{
width:36px;height:36px;border-radius:50%;border:none;background:transparent;
color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:all 0.15s
}
.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;
.icon-btn:hover{background:var(--surface2);color:var(--text)}
.avatar{
width:40px;height:40px;border-radius:50%;background:var(--purple);
display:flex;align-items:center;justify-content:center;
font-weight:600;font-size:15px;color:#fff;flex-shrink:0
}
.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;
.avatar.sm{width:32px;height:32px;font-size:13px}
.content{flex:1;padding:20px 16px;max-width:1200px;margin:0 auto;width:100%}
.greeting{display:flex;align-items:center;gap:14px;margin-bottom:28px}
.greeting-text h1{font-size:1.4rem;font-weight:600;color:var(--text);line-height:1.3}
.greeting-text p{color:var(--text2);font-size:0.9rem;margin-top:2px}
/* App Grid */
.app-grid{
display:grid;
grid-template-columns:repeat(2,1fr);
gap:12px
}
.app-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
@media(min-width:600px){.app-grid{grid-template-columns:repeat(3,1fr);gap:14px}}
@media(min-width:900px){.app-grid{grid-template-columns:repeat(4,1fr);gap:16px}}
@media(min-width:1200px){.app-grid{grid-template-columns:repeat(5,1fr);gap:18px}}
/* App Card */
.app-card{
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius); padding:20px 16px;
cursor:pointer; transition:all 0.2s; text-decoration:none; color:inherit;
display:flex; flex-direction:column; align-items:center; gap:10px;
min-height:140px; justify-content:center; position:relative
}
.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:active{transform:scale(0.97)}
.app-card:hover{border-color:var(--surface3); box-shadow:0 4px 16px rgba(0,0,0,0.2)}
.app-card .app-icon{
width:52px;height:52px;border-radius:var(--radius-sm);
display:flex;align-items:center;justify-content:center;flex-shrink:0
}
.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;
.app-card .app-icon svg{width:26px;height:26px}
.app-card .app-name{font-size:0.9rem;font-weight:500;color:var(--text);text-align:center}
.app-card .app-badge{
font-size:0.7rem;color:var(--text3);position:absolute;bottom:8px
}
.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;
.app-card.disabled{opacity:0.4;cursor:default}
.app-card.disabled:active{transform:none}
/* Overlay & Modal */
.overlay{
position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;
display:flex;align-items:center;justify-content:center;
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px)
}
.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;
.modal{
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius); width:92%; max-width:420px; max-height:85vh; overflow-y:auto
}
.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-head{
display:flex;justify-content:space-between;align-items:center;
padding:18px 20px 12px
}
.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-head h2{font-size:1.1rem;font-weight:600}
.modal-body{padding:0 20px 20px}
.close-btn{
width:32px;height:32px;border-radius:50%;border:none;background:transparent;
color:var(--text2);font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center
}
.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-body h3{font-size:0.85rem;font-weight:600;color:var(--text2);margin:14px 0 6px;text-transform:uppercase;letter-spacing:0.5px}
.modal-body ol{padding-left:18px}
.modal-body li{font-size:0.85rem;color:var(--text);margin-bottom:4px;line-height:1.5}
.modal-body code{background:var(--surface2);padding:1px 5px;border-radius:4px;font-size:0.8rem}
.modal-body .btn{
display:block;width:100%;padding:12px;margin-top:16px;border:none;border-radius:var(--radius-sm);
background:var(--blue);color:#fff;font-size:0.9rem;font-weight:600;cursor:pointer;text-align:center
}
.modal-close {
float: right;
background: none; border: none;
color: var(--text-muted); cursor: pointer;
font-size: 1.25rem;
}
.modal-close:hover { color: var(--text); }
.modal-body .btn:hover{opacity:0.9}
</style>
}
// --- Helpers ---
func initials(name string) string {
if name == "" { return "?" }
if len(name) == 1 { return name }
return string(name[0])
}
func timeOfDay() string {
// Simple: always show "morning" for now
return "morning"
}
// --- PWA Instructions ---
templ pwaInstructions() {
<h3>Desktop Chrome/Edge</h3>
<ol>
<li>Click the <code>⊕</code> icon in the address bar</li>
<li>Click <strong>Install</strong></li>
</ol>
<h3>iOS Safari</h3>
<ol>
<li>Tap <strong>Share</strong> <code>⎋</code></li>
<li>Tap <strong>Add to Home Screen</strong></li>
</ol>
<h3>Android Chrome</h3>
<ol>
<li>Tap <strong>⋮</strong> menu</li>
<li>Tap <strong>Install app</strong></li>
</ol>
<button class="btn" onclick="closePWA()">Got it</button>
}
// --- JavaScript ---
templ launcherJS() {
<script type="text/javascript">
let longPressTimer = null;
let currentUrl = null;
function openApp(url) {
window.open(url, '_blank');
}
function startLongPress(e, url) {
currentUrl = url || null;
longPressTimer = setTimeout(function() {
showPWA();
}, 600);
}
function cancelLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function showPWA() {
document.getElementById('pwa-overlay').style.display = 'flex';
}
function closePWA() {
document.getElementById('pwa-overlay').style.display = 'none';
}
// PWA install handler
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
});
// Override showPWA to try native prompt first
document.addEventListener('DOMContentLoaded', function() {
var origShowPWA = showPWA;
showPWA = function() {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function() { deferredPrompt = null; });
} else {
origShowPWA();
}
};
});
</script>
}

File diff suppressed because one or more lines are too long

View file

@ -149,6 +149,12 @@ func main() {
isAdmin, _ := roleChecker.IsAdmin(session.UserID)
if isAdmin {
ctx := context.WithValue(r.Context(), auth.ContextUserID, session.UserID)
ctx = context.WithValue(ctx, auth.ContextRole, "admin")
next.ServeHTTP(w, r.WithContext(ctx))
return
} else {
ctx := context.WithValue(r.Context(), auth.ContextUserID, session.UserID)
ctx = context.WithValue(ctx, auth.ContextRole, "user")
next.ServeHTTP(w, r.WithContext(ctx))
return
}