250 lines
33 KiB
Go
250 lines
33 KiB
Go
package httpserver
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"html/template"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.michelsen.id/phill/preface-tools/internal/auth"
|
||
"git.michelsen.id/phill/preface-tools/internal/tools"
|
||
)
|
||
|
||
type Server struct {
|
||
auth *auth.Service
|
||
registry *tools.Registry
|
||
log *slog.Logger
|
||
handler http.Handler
|
||
}
|
||
|
||
func New(a *auth.Service, r *tools.Registry, log *slog.Logger) *Server {
|
||
s := &Server{auth: a, registry: r, log: log}
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
|
||
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ready\n")) })
|
||
mux.HandleFunc("GET /static/app.css", css)
|
||
mux.HandleFunc("GET /static/app.js", js)
|
||
mux.HandleFunc("GET /login", s.loginPage)
|
||
mux.HandleFunc("POST /login/{role}", s.login)
|
||
mux.Handle("POST /logout", s.withAuth("", s.csrf(http.HandlerFunc(s.logout))))
|
||
mux.HandleFunc("/", s.root)
|
||
for _, tool := range r.List() {
|
||
key := tool.Definition().Key
|
||
studentPrefix := "/tools/" + key
|
||
suffix := http.StripPrefix(studentPrefix, tool.StudentHandler())
|
||
mux.Handle("GET "+studentPrefix+"/provider-media/", suffix)
|
||
mux.Handle(studentPrefix+"/", s.withAuth(auth.Student, s.csrf(s.shell(tool, suffix, false, studentPrefix+"/"))))
|
||
if it, ok := tool.(tools.InstructorTool); ok {
|
||
prefix := "/instructor/tools/" + key
|
||
mux.Handle(prefix+"/", s.withAuth(auth.Instructor, s.csrf(s.shell(tool, http.StripPrefix(prefix, it.InstructorHandler()), true, prefix+"/"))))
|
||
}
|
||
}
|
||
mux.Handle("/instructor", s.withAuth(auth.Instructor, http.HandlerFunc(s.instructorRoot)))
|
||
s.handler = s.middleware(mux)
|
||
return s
|
||
}
|
||
func (s *Server) Handler() http.Handler { return s.handler }
|
||
func (s *Server) root(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
c, err := s.auth.FromRequest(r)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||
return
|
||
}
|
||
if c.Role == auth.Instructor {
|
||
http.Redirect(w, r, "/instructor", 303)
|
||
return
|
||
}
|
||
list := s.registry.List()
|
||
if len(list) == 0 {
|
||
http.Error(w, "no tools configured", 503)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/tools/"+list[0].Definition().Key+"/", 303)
|
||
}
|
||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { render(w, loginTemplate, nil) }
|
||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||
role := auth.Role(r.PathValue("role"))
|
||
if role != auth.Student && role != auth.Instructor {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
c, err := s.auth.Authenticate(role, r.FormValue("pin"), clientIP(r)+":"+string(role))
|
||
if err != nil {
|
||
renderStatus(w, loginTemplate, map[string]any{"Error": "Login was not accepted."}, 401)
|
||
return
|
||
}
|
||
s.auth.SetCookie(w, c)
|
||
if role == auth.Instructor {
|
||
http.Redirect(w, r, "/instructor", 303)
|
||
} else {
|
||
http.Redirect(w, r, "/", 303)
|
||
}
|
||
}
|
||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||
s.auth.ClearCookie(w)
|
||
http.Redirect(w, r, "/login", 303)
|
||
}
|
||
func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
list := s.registry.List()
|
||
for _, t := range list {
|
||
if _, ok := t.(tools.InstructorTool); ok {
|
||
http.Redirect(w, r, "/instructor/tools/"+t.Definition().Key+"/", 303)
|
||
return
|
||
}
|
||
}
|
||
http.Error(w, "no instructor tools", 404)
|
||
}
|
||
func (s *Server) withAuth(role auth.Role, next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
c, err := s.auth.FromRequest(r)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/login", 303)
|
||
return
|
||
}
|
||
if role != "" && c.Role != role {
|
||
http.Error(w, "forbidden", 403)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, auth.WithClaims(r, c))
|
||
})
|
||
}
|
||
func (s *Server) csrf(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
c, _ := auth.ClaimsFrom(r)
|
||
token := r.Header.Get("X-CSRF-Token")
|
||
if token == "" {
|
||
token = r.FormValue("csrf_token")
|
||
}
|
||
if token == "" || token != c.CSRFToken {
|
||
http.Error(w, "invalid CSRF token", 403)
|
||
return
|
||
}
|
||
if origin := r.Header.Get("Origin"); origin != "" && !strings.HasPrefix(origin, "http://"+r.Host) && !strings.HasPrefix(origin, "https://"+r.Host) {
|
||
http.Error(w, "invalid origin", 403)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool, rootPath string) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != rootPath {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
rw := &capture{header: http.Header{}}
|
||
next.ServeHTTP(rw, r)
|
||
if rw.status >= 400 {
|
||
copyHeader(w.Header(), rw.header)
|
||
w.WriteHeader(rw.status)
|
||
w.Write(rw.body)
|
||
return
|
||
}
|
||
c, _ := auth.ClaimsFrom(r)
|
||
data := map[string]any{"Title": tool.Definition().Name, "Content": template.HTML(rw.body), "Tools": s.registry.List(), "Selected": tool.Definition().Key, "Instructor": instructor, "CSRF": c.CSRFToken}
|
||
render(w, shellTemplate, data)
|
||
})
|
||
}
|
||
|
||
type capture struct {
|
||
header http.Header
|
||
body []byte
|
||
status int
|
||
}
|
||
|
||
func (c *capture) Header() http.Header { return c.header }
|
||
func (c *capture) WriteHeader(n int) { c.status = n }
|
||
func (c *capture) Write(b []byte) (int, error) {
|
||
if c.status == 0 {
|
||
c.status = 200
|
||
}
|
||
c.body = append(c.body, b...)
|
||
return len(b), nil
|
||
}
|
||
func copyHeader(dst, src http.Header) {
|
||
for k, v := range src {
|
||
dst[k] = v
|
||
}
|
||
}
|
||
func (s *Server) middleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
b := make([]byte, 8)
|
||
rand.Read(b)
|
||
rid := hex.EncodeToString(b)
|
||
w.Header().Set("X-Request-ID", rid)
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("Referrer-Policy", "same-origin")
|
||
w.Header().Set("X-Frame-Options", "DENY")
|
||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https://www.preface.ai; media-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||
defer func() {
|
||
if v := recover(); v != nil {
|
||
s.log.Error("request panic", "request_id", rid, "error", v)
|
||
http.Error(w, "internal server error", 500)
|
||
}
|
||
s.log.Info("request", "request_id", rid, "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
|
||
}()
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
func clientIP(r *http.Request) string {
|
||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||
if err == nil {
|
||
return host
|
||
}
|
||
return r.RemoteAddr
|
||
}
|
||
func render(w http.ResponseWriter, src string, data any) { renderStatus(w, src, data, 200) }
|
||
func renderStatus(w http.ResponseWriter, src string, data any, status int) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.WriteHeader(status)
|
||
template.Must(template.New("page").Parse(src)).Execute(w, data)
|
||
}
|
||
|
||
const head = `<!doctype html><html lang="en" data-theme="silk"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true},{"code":"...","swap":false,"error":true}]}'><title>{{if .Title}}{{.Title}} · {{end}}Preface Tools</title><link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin><link rel="preconnect" href="https://www.preface.ai" crossorigin><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/daisyui@5.6.3/daisyui.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/daisyui@5.6.3/themes.css"><link rel="stylesheet" href="/static/app.css"><script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" defer></script><script src="/static/app.js" defer></script></head><body><div class="service-banner" role="alert"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9v4m0 4h.01M10.3 3.7 2.4 17.4A2 2 0 0 0 4.1 20h15.8a2 2 0 0 0 1.7-2.6L13.7 3.7a2 2 0 0 0-3.4 0Z"/></svg><div class="service-banner-text"><strong>Video generation is currently unavailable.</strong><span>The provided API key has expired or run out of credits, so new video generations cannot be started until it has been renewed. Prompt generation may also be affected.</span></div></div><dialog id="outage-dialog" class="modal"><div class="modal-box"><div class="modal-header"><div><span class="eyebrow service-eyebrow">Service notice</span><h2>Video generation unavailable</h2><p>New animations cannot be started right now.</p></div><form method="dialog"><button class="btn btn-circle btn-ghost btn-sm" aria-label="Close notice">×</button></form></div><div class="modal-content"><p>Unfortunately, video generation is currently unoperational because the provided API key has either expired or run out of credits. It will work again once the key has been renewed or topped up.</p><div class="modal-action"><form method="dialog"><button class="btn btn-primary">Understood</button></form></div></div></div><form method="dialog" class="modal-backdrop"><button aria-label="Close dialog">close</button></form></dialog>`
|
||
const loginTemplate = head + `<main class="login-simple"><section class="login-box"><div class="login-brand"><img src="https://www.preface.ai/_next/image/?url=https%3A%2F%2Fres.cloudinary.com%2Fpreface%2Fimage%2Fupload%2Fw_1024%2Cc_limit%2Cf_auto%2Fv1633826301%2Fr2021%2Fassets%2Fwebsite-revamp%2Fpreface_logo&w=256&q=75" alt="Preface"><h1>Preface Tools</h1><p>Choose your classroom access</p></div>{{with .Error}}<div class="alert alert-error" role="alert"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9v4m0 4h.01M10.3 3.7 2.4 17.4A2 2 0 0 0 4.1 20h15.8a2 2 0 0 0 1.7-2.6L13.7 3.7a2 2 0 0 0-3.4 0Z"/></svg><span>{{.}}</span></div>{{end}}<div class="login-options"><form class="card login-card" method="post" action="/login/student"><div class="card-body"><div class="login-card-title"><span class="login-card-icon student-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m3 10 9-5 9 5-9 5-9-5Zm3 2.5V17c3 2.7 9 2.7 12 0v-4.5"/></svg></span><div><h2>Student</h2><p>Access classroom tools</p></div></div><label class="form-control"><span class="label-text">Student PIN</span><input class="input input-bordered" type="password" name="pin" inputmode="numeric" required autocomplete="current-password" placeholder="Enter PIN"></label><button class="btn btn-primary btn-block">Continue</button></div></form><form class="card login-card" method="post" action="/login/instructor"><div class="card-body"><div class="login-card-title"><span class="login-card-icon instructor-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm8-3 2 2 4-4"/></svg></span><div><h2>Instructor</h2><p>Review and recover outputs</p></div></div><label class="form-control"><span class="label-text">Instructor PIN</span><input class="input input-bordered" type="password" name="pin" inputmode="numeric" required autocomplete="current-password" placeholder="Enter PIN"></label><button class="btn btn-neutral btn-block">Continue</button></div></form></div></section></main></body></html>`
|
||
const shellTemplate = head + `<header class="app-header"><div class="navbar app-navbar"><div class="navbar-start"><a class="brand" href="/"><img class="brand-logo" src="https://www.preface.ai/_next/image/?url=https%3A%2F%2Fres.cloudinary.com%2Fpreface%2Fimage%2Fupload%2Fw_1024%2Cc_limit%2Cf_auto%2Fv1633826301%2Fr2021%2Fassets%2Fwebsite-revamp%2Fpreface_logo&w=256&q=75" alt="Preface"><span class="brand-divider"></span><span>Preface Tools</span></a></div><div class="navbar-end app-actions">{{if .Instructor}}<div class="role-chip"><span class="status status-success"></span>Instructor console</div>{{else}}<label class="tool-picker"><span>Current tool</span><span class="select-wrap"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z"/></svg><select class="select" id="tool-selector" aria-label="Choose tool">{{range .Tools}}<option value="/tools/{{.Definition.Key}}/" {{if eq .Definition.Key $.Selected}}selected{{end}}>{{.Definition.Name}}</option>{{end}}</select></span></label>{{end}}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="btn btn-ghost btn-sm" aria-label="Log out"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 17l5-5-5-5m5 5H3m10-9h6a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-6"/></svg><span>Log out</span></button></form></div></div></header><main class="app-main">{{.Content}}</main></body></html>`
|
||
|
||
func css(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/css")
|
||
fmt.Fprint(w, `
|
||
:root,[data-theme=preface]{color-scheme:light;--color-base-100:#fff;--color-base-200:#f6f7fb;--color-base-300:#e7e9f1;--color-base-content:#182033;--color-primary:#6657e8;--color-primary-content:#fff;--color-secondary:#e9e6ff;--color-secondary-content:#4438b8;--color-accent:#8b7cf6;--color-neutral:#20283a;--color-neutral-content:#fff;--color-info:#3977d6;--color-success:#168567;--color-warning:#b66a16;--color-error:#c13d4d;--radius-selector:.65rem;--radius-field:.75rem;--radius-box:1rem;--border:1px;--depth:0;--noise:0}
|
||
*{box-sizing:border-box}html{background:#f6f7fb}body{min-height:100vh;margin:0;background:radial-gradient(circle at 15% 0%,rgba(102,87,232,.07),transparent 28rem),#f6f7fb;color:#182033;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit}svg{width:1.15rem;height:1.15rem;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}.brand{display:inline-flex;align-items:center;gap:.7rem;color:#182033;font-size:.78rem;font-weight:800;letter-spacing:.115em;text-decoration:none}.brand-large{font-size:.9rem}.brand-mark{display:grid;width:2.15rem;height:2.15rem;place-items:center;border-radius:.7rem;background:#6657e8;color:#fff;font-size:1rem;letter-spacing:0;box-shadow:0 6px 18px rgba(102,87,232,.22)}.eyebrow{display:block;margin-bottom:.75rem;color:#6657e8;font-size:.72rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase}.app-header{position:sticky;z-index:30;top:0;border-bottom:1px solid rgba(218,221,231,.9);background:rgba(255,255,255,.88);backdrop-filter:blur(18px)}.app-navbar{width:min(1480px,100%);min-height:4.5rem;margin:auto;padding:0 clamp(1rem,3vw,2.5rem)}.app-actions{gap:.75rem}.app-actions form{margin:0}.app-actions .btn svg{width:1rem}.tool-picker{display:flex;align-items:center;gap:.6rem}.tool-picker>span{color:#737b8f;font-size:.75rem;font-weight:700}.tool-picker .select{width:12rem;height:2.45rem;min-height:2.45rem;background:#fff;font-size:.85rem}.role-chip{display:flex;align-items:center;gap:.5rem;border:1px solid #e1e4ec;border-radius:999px;background:#fff;padding:.5rem .8rem;color:#4c556a;font-size:.78rem;font-weight:700}.app-main{width:min(1480px,100%);margin:0 auto;padding:clamp(1.25rem,3vw,2.5rem)}
|
||
.login-page{display:grid;min-height:100vh;grid-template-columns:minmax(20rem,.8fr) minmax(36rem,1.2fr);padding:0}.login-intro{position:relative;display:flex;overflow:hidden;min-height:100vh;flex-direction:column;justify-content:space-between;padding:clamp(2rem,5vw,5rem);background:#1e2638;color:#fff}.login-intro:after{position:absolute;right:-10rem;bottom:-12rem;width:35rem;height:35rem;border:1px solid rgba(255,255,255,.08);border-radius:50%;box-shadow:0 0 0 5rem rgba(255,255,255,.025),0 0 0 10rem rgba(255,255,255,.018);content:""}.login-intro .brand{color:#fff}.login-intro .brand-mark{background:#7869ef}.login-intro>div{position:relative;z-index:1;max-width:34rem}.login-intro h1{max-width:9ch;margin:0 0 1.25rem;font-size:clamp(3rem,5vw,5.4rem);font-weight:700;letter-spacing:-.055em;line-height:.96}.login-intro p{max-width:31rem;margin:0;color:#b9c0d0;font-size:1.05rem;line-height:1.75}.login-intro .eyebrow{color:#9f94ff}.login-note{display:flex;align-items:center;gap:.65rem;color:#cbd1dd;font-size:.8rem}.login-panel{display:grid;min-height:100vh;place-items:center;padding:clamp(1.5rem,5vw,5rem)}.login-panel-inner{width:min(100%,52rem)}.login-heading{margin-bottom:2rem}.login-heading h2{margin:0 0 .6rem;font-size:clamp(1.8rem,3vw,2.5rem);font-weight:700;letter-spacing:-.035em}.login-heading p{margin:0;color:#747c8f}.login-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.login-card{border:1px solid #e1e4ec;background:#fff;box-shadow:0 16px 50px rgba(25,32,51,.07);transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease}.login-card:hover{transform:translateY(-2px);border-color:#c9c4fa;box-shadow:0 20px 55px rgba(25,32,51,.1)}.login-card .card-body{gap:1.15rem;padding:1.5rem}.login-card .card-title{margin:0;font-size:1.15rem}.login-card p{margin:.25rem 0 0;color:#767e91;font-size:.85rem;line-height:1.5}.login-card-icon{display:grid;width:2.8rem;height:2.8rem;place-items:center;border-radius:.85rem}.login-card-icon svg{width:1.35rem;height:1.35rem}.student-icon{background:#ece9ff;color:#5849d8}.instructor-icon{background:#e8f3f0;color:#13735a}.form-control{display:grid;gap:.45rem}.label-text{color:#4e5669;font-size:.78rem;font-weight:700}.input,.select,.textarea,.file-input{outline:none}.input:focus,.select:focus,.textarea:focus,.file-input:focus{border-color:#7769ee;box-shadow:0 0 0 3px rgba(102,87,232,.12)}.alert{margin-bottom:1rem;font-size:.85rem}.alert svg{flex:none}
|
||
.workspace-heading{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:1.5rem}.workspace-heading h1{margin:.2rem 0 .35rem;font-size:clamp(1.7rem,3vw,2.35rem);font-weight:700;letter-spacing:-.04em}.workspace-heading p{margin:0;color:#737b8e;font-size:.92rem}.workspace-kicker{display:flex;align-items:center;gap:.45rem;color:#6557dc;font-size:.73rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase}.workspace-kicker svg{width:.9rem}.workflow-steps{display:flex;gap:.45rem}.workflow-step{display:flex;align-items:center;gap:.4rem;border:1px solid #e0e3eb;border-radius:999px;background:#fff;padding:.4rem .65rem;color:#70788a;font-size:.7rem;font-weight:700}.workflow-step b{display:grid;width:1.25rem;height:1.25rem;place-items:center;border-radius:50%;background:#ece9ff;color:#5b4cda;font-size:.65rem}.tool-grid{display:grid;grid-template-columns:minmax(15rem,.85fr) minmax(23rem,1.35fr) minmax(15rem,.75fr);gap:1rem;align-items:stretch}.workspace-card{overflow:hidden;border:1px solid #e2e4ec;background:#fff;box-shadow:0 8px 30px rgba(29,37,56,.055)}.workspace-card .card-body{gap:1rem;padding:1.25rem}.card-heading{display:flex;align-items:flex-start;gap:.8rem}.step-number{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#eeecff;color:#5d4fdb;font-size:.75rem;font-weight:800}.card-heading h2{margin:.05rem 0 .2rem;font-size:.98rem;font-weight:750}.card-heading p{margin:0;color:#808798;font-size:.75rem;line-height:1.45}.upload-zone{display:grid;min-height:19rem;place-items:center;border:1.5px dashed #d7d9e5;border-radius:.85rem;background:#fafafe;text-align:center;transition:border-color .2s,background .2s}.upload-zone:hover{border-color:#9b91ed;background:#f8f7ff}.upload-empty{padding:1.25rem}.upload-icon{display:grid;width:3.25rem;height:3.25rem;margin:0 auto .85rem;place-items:center;border-radius:1rem;background:#ece9ff;color:#5b4cda}.upload-icon svg{width:1.45rem;height:1.45rem}.upload-empty strong{display:block;margin-bottom:.3rem;font-size:.88rem}.upload-empty p{margin:0 0 1rem;color:#8990a1;font-size:.73rem}.upload-empty .file-input{width:100%;max-width:16rem}.preview{position:relative;width:100%;height:100%;padding:.75rem}.preview img{width:100%;height:17.5rem;border-radius:.7rem;object-fit:contain;background:#eef0f5}.preview p{overflow:hidden;margin:.65rem 0 0;color:#687084;font-size:.72rem;text-overflow:ellipsis;white-space:nowrap}.motion-form{display:flex;min-height:100%;flex-direction:column}.motion-form .textarea{min-height:16.5rem;resize:vertical;background:#fbfbfd;line-height:1.6}.helper-copy{margin:0;color:#7b8294;font-size:.76rem;line-height:1.55}.prompt-actions{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.15rem}.prompt-result{margin-top:.25rem}.prompt-result:empty{display:none}.prompt-result label{display:block;margin:1rem 0 .45rem;color:#4e5669;font-size:.78rem;font-weight:750}.prompt-result textarea{width:100%;min-height:11rem;border:1px solid #d8dbe5;border-radius:.75rem;background:#f8f9fc;padding:.8rem;font-size:.82rem;line-height:1.55;resize:vertical}.prompt-result small{display:block;margin-top:.35rem;text-align:right}.generate-card{background:linear-gradient(160deg,#fff 0%,#faf9ff 100%)}.settings-list{display:grid;gap:.75rem;margin:.3rem 0}.setting-row{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #eceef3;padding:.65rem 0}.setting-row span{color:#747c8e;font-size:.77rem}.setting-value{display:flex!important;width:auto!important;min-width:4rem;margin:0!important;text-align:center;font-weight:750}.cost-note{display:flex;gap:.65rem;border:1px solid #e5e1ff;border-radius:.75rem;background:#f5f3ff;padding:.75rem;color:#5f55a6;font-size:.73rem;line-height:1.45}.cost-note svg{width:1rem;flex:none}.generate-card .btn-primary{margin-top:auto}.recent-section{margin-top:1.25rem}.section-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:.85rem}.section-heading h2{margin:0;font-size:1.05rem}.section-heading p{margin:.2rem 0 0;color:#838a9b;font-size:.75rem}.history-shell{min-height:7rem;border:1px solid #e1e4ec;border-radius:1rem;background:#fff;padding:0 1.2rem;box-shadow:0 8px 30px rgba(29,37,56,.04)}.history-shell:empty:after{display:grid;min-height:7rem;place-items:center;color:#9299a8;font-size:.8rem;content:"No animations in this session yet"}.generation{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.8rem 1.25rem;border-bottom:1px solid #eceef3;padding:1.15rem 0}.generation:last-child{border-bottom:0}.generation-header{display:flex;align-items:center;gap:.65rem}.generation-header small{color:#8a91a0}.generation-body{grid-column:1/-1}.generation video{width:min(100%,44rem);max-height:30rem;border-radius:.8rem;background:#151923}.generation-actions{display:flex;align-items:center;gap:.5rem}.generation details{grid-column:1/-1}.generation details p{max-width:70ch;color:#626a7d;font-size:.8rem;line-height:1.6}.status-badge{text-transform:capitalize}.working-state{display:flex;align-items:center;gap:.55rem;color:#767e91;font-size:.8rem}.muted,small{color:#7b8293}.error{color:#b3293b;font-size:.8rem}.htmx-indicator{display:none}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:inline-flex}.modal{background:rgba(17,23,36,.45);backdrop-filter:blur(4px)}.modal-box{max-width:29rem;border:1px solid #e3e5ec;padding:0;box-shadow:0 28px 90px rgba(17,23,36,.24)}.modal-header{display:flex;align-items:flex-start;justify-content:space-between;border-bottom:1px solid #eceef3;padding:1.35rem}.modal-header h2{margin:0 0 .3rem;font-size:1.15rem}.modal-header p{margin:0;color:#7a8294;font-size:.78rem}.modal-content{display:grid;gap:1rem;padding:1.35rem}.modal-action{margin:0;padding-top:.25rem}.instructor-grid{display:grid;gap:1.25rem}.instructor-panel{border:1px solid #e1e4ec;background:#fff;box-shadow:0 8px 30px rgba(29,37,56,.045)}.instructor-panel .card-body{padding:1.35rem}.output-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(18rem,1fr));gap:1rem}.output-card{overflow:hidden;border:1px solid #e4e6ed;border-radius:.85rem;background:#fafbfc}.output-card video{width:100%;height:12rem;background:#171b26;object-fit:contain}.output-meta{display:grid;gap:.5rem;padding:.85rem}.output-meta strong{overflow:hidden;font-size:.78rem;text-overflow:ellipsis;white-space:nowrap}.output-meta small{font-size:.68rem}
|
||
.login-simple{display:grid;min-height:100vh;place-items:center;padding:2rem;background:#f7f7f4}.login-box{width:min(100%,48rem)}.login-brand{text-align:center;margin-bottom:2rem}.login-brand img{width:8.75rem;height:auto;margin:0 auto 1.15rem}.login-brand h1{margin:0;color:#1d2433;font-size:2rem;font-weight:650;letter-spacing:-.04em}.login-brand p{margin:.45rem 0 0;color:#7c827f;font-size:.88rem}.login-simple .login-options{grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.login-simple .login-card{box-shadow:none}.login-simple .login-card .card-body{gap:1.2rem;padding:1.35rem}.login-card-title{display:flex;align-items:center;gap:.8rem}.login-card-title h2{margin:0;font-size:1rem;font-weight:700}.login-card-title p{margin:.15rem 0 0;font-size:.75rem}.login-simple .login-card-icon{margin:0;flex:none}.brand-logo{width:6.6rem;height:auto;object-fit:contain}.brand-divider{width:1px;height:1.35rem;background:#dcded9}.brand{gap:.75rem;color:#252c38;font-size:.83rem;font-weight:650;letter-spacing:0;text-transform:none}.tool-picker{gap:.7rem}.tool-picker>span:first-child{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em}.select-wrap{display:flex;align-items:center;gap:.45rem;border:1px solid #dedfd9;border-radius:.65rem;background:#fff;padding-left:.7rem;box-shadow:0 1px 2px rgba(20,25,30,.04)}.select-wrap svg{width:.95rem;color:#6761c8}.tool-picker .select{width:10.75rem;border:0;background:transparent;padding-left:.1rem;box-shadow:none;font-weight:650}.tool-picker .select:focus{box-shadow:none}.workspace-heading{margin-bottom:1.75rem}.tool-grid{gap:1.15rem}.workspace-card .card-body{padding:1.4rem}.upload-empty{display:flex;width:100%;flex-direction:column;align-items:center}.upload-empty .file-input{margin-bottom:.65rem}.upload-empty .btn{width:100%;max-width:16rem}.prompt-actions{align-items:flex-end;flex-direction:column}.prompt-actions>.muted{align-self:flex-start}.prompt-actions .btn{width:100%;height:2.75rem;background:#5f57c8;color:#fff;border-color:#5f57c8}.prompt-actions .btn:hover{background:#5048b7;border-color:#5048b7}.section-heading{min-height:2.75rem}.section-heading>div{min-width:0}.section-heading>span{flex:none}.recent-section .section-heading{padding:0 .15rem}.history-shell{padding:0 1.35rem}
|
||
.workspace-heading{display:none}.app-main{padding-top:.75rem}.tool-grid{grid-template-columns:minmax(15rem,.8fr) minmax(24rem,1.25fr) minmax(16rem,.75fr);gap:.75rem}.workspace-card{border-radius:.65rem;box-shadow:0 2px 8px rgba(29,37,56,.035)}.workspace-card .card-body{gap:.8rem;padding:1rem}.card-heading{gap:.45rem}.step-number{display:block;width:auto;height:auto;border-radius:0;background:none;color:#182033;font-size:.78rem}.step-number:after{content:"."}.card-heading h2{margin:0 0 .15rem;font-size:.82rem}.card-heading p{font-size:.68rem}.upload-zone{min-height:17rem;border-radius:.5rem;background:#fff}.upload-icon{width:2.6rem;height:2.6rem;margin-bottom:.6rem;border-radius:.65rem}.upload-empty strong{font-size:.78rem}.upload-empty p{margin-bottom:.7rem;font-size:.66rem}.preview img{height:15rem}.motion-form .textarea{min-height:11rem;background:#fff;font-size:.78rem}.helper-copy{font-size:.68rem}.prompt-result label{margin-top:.65rem}.prompt-result textarea{min-height:7rem;border-radius:.5rem;background:#fff}.generate-card{background:#fff}.settings-list{gap:.35rem}.cost-note{border:0;background:transparent;padding:.25rem 0;color:#737b8e}.recent-section{margin-top:.75rem;border:1px solid #e1e4ec;border-radius:.65rem;background:#fff;padding:.9rem}.recent-section .section-heading{margin:0 0 .4rem;padding:0}.recent-section .section-heading h2{font-size:.82rem}.recent-section .section-heading p{font-size:.67rem}.recent-section .history-shell{border:0;border-radius:0;padding:0;box-shadow:none}.login-simple{position:relative;place-items:center;background:#fafaf8}.login-simple:before{position:absolute;top:2rem;left:2.25rem;color:#162238;font-size:.72rem;font-weight:850;letter-spacing:.13em;content:"PREFACE TOOLS"}.login-box{width:min(100%,42rem)}.login-brand img{width:7.75rem}.login-brand h1{font-size:1.8rem}.login-brand p:before{content:"Welcome to ";color:#273348}.login-simple .login-card{border-radius:.65rem;box-shadow:0 4px 14px rgba(29,37,56,.06)}.login-simple .login-card .card-body{padding:1.15rem}.login-simple:after{position:absolute;bottom:2rem;color:#78808d;font-size:.65rem;content:"© 2025 Preface Tools"}
|
||
.workspace-heading:has(+.instructor-grid){display:flex}.brand-logo,.login-brand img{filter:brightness(0)}.login-simple:before,.login-simple:after{content:none}.tool-picker>span:first-child,.app-actions .btn span{display:none}.tool-grid{min-height:clamp(31rem,62vh,43rem)}.workspace-card>.card-body{height:100%;min-height:0}.upload-zone{flex:1}.motion-form{display:flex;width:100%;min-height:0;flex:1;gap:.65rem}.motion-form>.textarea{width:100%;min-height:0;flex:1;resize:none}.motion-form>.helper-copy{flex:none}.prompt-actions{width:100%;flex:none;margin-top:0}.prompt-actions>.muted{display:none}.prompt-actions .btn{flex:none}.prompt-actions .btn:disabled{cursor:wait;opacity:.65}.generate-card .settings-list{display:flex;min-height:18rem;flex:1;flex-direction:column;margin:0;border:1px solid #e1e4ec;border-radius:.55rem;background:#fafbfc;padding:.75rem}.generate-card .settings-list>.setting-row{display:none}.generate-card .settings-list:empty:after{color:#858c9b;font-size:.72rem;line-height:1.5;content:"Generate a video prompt from your description to review it here."}.generate-card .prompt-result{display:flex;width:100%;height:100%;min-height:0;flex:1;flex-direction:column;margin:0}.generate-card .prompt-result:empty:after{color:#858c9b;font-size:.72rem;line-height:1.5;content:"Generate a video prompt from your description to review it here."}.generate-card .prompt-result label{flex:none;margin:0 0 .45rem}.generate-card .prompt-result textarea{width:100%;min-height:0;flex:1;resize:none}.generate-card .prompt-result small{flex:none}
|
||
.recent-section .section-heading>span{display:none}.generate-card .step-number{font-size:0}.generate-card .step-number:before{font-size:.78rem;content:"03"}
|
||
@media(max-width:1050px){.tool-grid{grid-template-columns:1fr 1.25fr}.generate-card{grid-column:1/-1}.generate-card .card-body{display:grid;grid-template-columns:1fr 1fr;align-items:center}.generate-card .card-heading{grid-column:1/-1}.login-page{grid-template-columns:.75fr 1.25fr}.login-options{grid-template-columns:1fr}}
|
||
@media(max-width:720px){.app-navbar{min-height:4rem}.brand-logo{width:5.4rem}.brand-divider,.brand>span:last-child{display:none}.tool-picker>span:first-child,.app-actions .btn span{display:none}.tool-picker .select{width:9rem}.app-main{padding:1rem}.workspace-heading{align-items:flex-start;flex-direction:column;gap:1rem}.workflow-steps{overflow:auto;width:100%;padding-bottom:.25rem}.workflow-step{white-space:nowrap}.tool-grid{grid-template-columns:1fr}.generate-card{grid-column:auto}.generate-card .card-body{display:flex}.login-simple{padding:1rem}.login-simple .login-options{grid-template-columns:1fr}.login-brand{margin-bottom:1.5rem}.login-brand img{width:7.5rem}.generation{grid-template-columns:1fr}.generation-actions{grid-column:1}.output-grid{grid-template-columns:1fr}.upload-zone{min-height:16rem}}
|
||
.service-banner{display:flex;align-items:flex-start;gap:.75rem;border-bottom:1px solid rgba(182,106,22,.25);background:#fdf3e0;color:#7a4d10;padding:.75rem clamp(1rem,3vw,2.5rem)}.service-banner svg{flex:none;width:1.2rem;height:1.2rem;margin-top:.1rem;color:#b66a16}.service-banner-text{display:grid;gap:.15rem}.service-banner-text strong{font-size:.85rem;font-weight:750;color:#8a5510}.service-banner-text span{font-size:.78rem;line-height:1.5;color:#7a4d10}
|
||
.service-eyebrow{color:#b66a16}
|
||
`)
|
||
}
|
||
func js(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/javascript")
|
||
fmt.Fprint(w, `const placePrompt=()=>{const p=document.querySelector("#prompt-result"),s=document.querySelector(".generate-card .settings-list");if(p&&s&&!s.contains(p)){s.replaceChildren(p)}};const promptButton=e=>e.detail.elt?.matches?.('[hx-post="prompt"]')?e.detail.elt:null;document.addEventListener("DOMContentLoaded",placePrompt);document.addEventListener("htmx:beforeRequest",e=>{const b=promptButton(e);if(b){b.disabled=true;b.setAttribute("aria-busy","true")}});document.addEventListener("htmx:afterRequest",e=>{const b=promptButton(e);if(b){b.disabled=false;b.removeAttribute("aria-busy")}});document.addEventListener("htmx:afterSwap",placePrompt);document.addEventListener("change",e=>{if(e.target.id==="tool-selector")location.href=e.target.value});document.addEventListener("click",e=>{if(e.target.closest("[data-open-approval]"))document.querySelector("#approval-dialog")?.showModal();if(e.target.closest("[data-close-approval]"))document.querySelector("#approval-dialog")?.close()});document.addEventListener("generationQueued",()=>{const d=document.querySelector("#approval-dialog"),p=d?.querySelector('[name="instructor_pin"]');if(p)p.value="";d?.close()});document.addEventListener("DOMContentLoaded",()=>{document.querySelector("#outage-dialog")?.showModal()});`)
|
||
}
|