248 lines
31 KiB
Go
248 lines
31 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>`
|
|
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}}
|
|
`)
|
|
}
|
|
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()});`)
|
|
}
|