feat(ui): Google Workspace-style redesign, role-based admin, long-press PWA
This commit is contained in:
parent
cb4eb38289
commit
2579715cbc
7 changed files with 598 additions and 507 deletions
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()">×</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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue