From e12a5e7e8959aed5a42caea0f76b9544496dbe46 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:42:58 +0200 Subject: [PATCH] feat(web): upload routes -- form, multipart intake, honest status (ADR-033) GET/POST /uploads, GET /uploads/{id}, GET /uploads/{id}/status, mounted only when an Uploader is wired. The multipart body is streamed to Intake, never buffered. Rejections are synchronous: 422 (video, too long, unreadable) or 413 with the user-facing message and no row. Another user's id is a 404 that does not leak the title. Unfinished states poll; done/failed render without hx-trigger so nothing polls forever (ADR-025). A done transcript names the endpoint that served it. handlers.go, uploads.go and the templ pair are one commit: the App fields and Store methods are what make the handlers compile. Mutation-checked: dropping the 413 mapping, the not-found mapping, or letting a done card poll each turn a test red. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/handlers.go | 21 ++ internal/web/uploads.go | 191 +++++++++++++++++ internal/web/uploads.templ | 91 ++++++++ internal/web/uploads_templ.go | 381 ++++++++++++++++++++++++++++++++++ 4 files changed, 684 insertions(+) create mode 100644 internal/web/uploads.go create mode 100644 internal/web/uploads.templ create mode 100644 internal/web/uploads_templ.go diff --git a/internal/web/handlers.go b/internal/web/handlers.go index bbf17d0..b297dce 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -13,6 +13,7 @@ import ( "git.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/uploads" ) // Store is the read/write surface the web handlers depend on — a narrow port over @@ -53,6 +54,12 @@ type Store interface { // the most recent discovery pass, shown on the account page as a warning. ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error) + // Uploaded-audio transcripts (ADR-033): the caller's own PRIVATE uploads, + // stored apart from the shared public captions. Both are user-scoped; a + // non-owner's id is ErrUploadNotFound. + GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error) + ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error) + // SetTranscriptStatus clears or updates a video's transcript backoff state. // Used by handleRetryNow to clear rate_limited_at before immediate processing. SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error @@ -100,6 +107,12 @@ type App struct { // summary view shows no "dig deeper" affordance. It holds no caption-fetch // dependency, so an enabled chat cannot reach YouTube or the rate gate. Chat Chatter + // Uploads, when non-nil, accepts audio recordings for private transcription + // (ADR-033). Nil = the /uploads routes are not mounted. UploadLimits are the + // same limits the Uploader enforces, shown on the form so they are stated up + // front rather than discovered by failing. + Uploads Uploader + UploadLimits uploads.Limits // Processing tracks in-flight immediate summarizations so the status endpoint // shows the animation until the summary lands. The zero value is ready to use. Processing ProcessingSet @@ -166,6 +179,14 @@ func (a *App) Router() http.Handler { app.HandleFunc("GET /v/{videoId}/chat", a.handleChat) app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage) } + // Audio upload → private transcript (ADR-033). Mounted only when an Uploader is + // wired; every handler is user-scoped, so another user's upload is a 404. + if a.Uploads != nil { + app.HandleFunc("GET /uploads", a.handleUploadsPage) + app.HandleFunc("POST /uploads", a.handleUploadPost) + app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail) + app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus) + } app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("POST /register", a.handleRegister) diff --git a/internal/web/uploads.go b/internal/web/uploads.go new file mode 100644 index 0000000..abb8711 --- /dev/null +++ b/internal/web/uploads.go @@ -0,0 +1,191 @@ +package web + +import ( + "context" + "errors" + "io" + "net/http" + "path/filepath" + "strings" + "unicode/utf8" + + "git.d-ma.be/mathias/tapir/internal/adapters/store" + "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/uploads" +) + +// Uploader accepts an uploaded recording for transcription (ADR-033). +// *uploads.Intake satisfies it. It is the whole of what the handlers know about +// validation, spooling and queueing. +type Uploader interface { + Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error) +} + +const ( + // uploadOverhead is headroom above the file-size cap for the multipart framing + // and the title field; the cap itself is enforced on the file part by Intake. + uploadOverhead = 1 << 20 + maxTitleRunes = 200 + // uploadPollEvery is how often an unfinished upload re-asks for its status. + uploadPollEvery = "5s" +) + +// uploadsView is what the /uploads page renders. +type uploadsView struct { + Limits uploads.Limits + Rows []domain.UploadedTranscript + Error string // a synchronous rejection message, empty when none +} + +// handleUploadsPage renders the upload form and the caller's own uploads. +func (a *App) handleUploadsPage(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + a.renderUploadsPage(w, r, userID, http.StatusOK, "") +} + +func (a *App) renderUploadsPage(w http.ResponseWriter, r *http.Request, userID string, status int, msg string) { + rows, err := a.Store.ListUploads(r.Context(), userID) + if err != nil { + a.serverError(w, r, "list uploads", err) + return + } + a.renderStatus(w, r, status, UploadsPage(uploadsView{Limits: a.UploadLimits, Rows: rows, Error: msg})) +} + +// handleUploadPost streams the multipart body: the optional title field, then the +// file part, which goes straight to Intake without being buffered in memory. A +// rejection is synchronous — a status code and a message on the upload page, no +// row, no timeout (ADR-033). +func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead) + mr, err := r.MultipartReader() + if err != nil { + a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.") + return + } + title := "" + for { + part, err := mr.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + a.uploadFailure(w, r, userID, err) + return + } + switch part.FormName() { + case "title": + b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax)) + title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes) + case "file": + if title == "" { + title = titleFromFilename(part.FileName()) + } + id, err := a.Uploads.Accept(r.Context(), userID, title, part) + if err != nil { + a.uploadFailure(w, r, userID, err) + return + } + http.Redirect(w, r, "/uploads/"+id, http.StatusSeeOther) + return + } + } + a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.") +} + +// uploadFailure maps an Accept (or multipart) error to a response: a validation +// rejection is the user's to fix (422, or 413 for size) and carries its own +// message; anything else is ours. +func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) { + var rej *uploads.RejectError + var tooBig *http.MaxBytesError + switch { + case errors.As(err, &rej): + status := http.StatusUnprocessableEntity + if rej.Reason == uploads.ReasonTooLarge { + status = http.StatusRequestEntityTooLarge + } + a.renderUploadsPage(w, r, userID, status, rej.Message) + case errors.As(err, &tooBig): + a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge, + "This file is larger than the upload limit.") + default: + a.serverError(w, r, "upload", err) + } +} + +// handleUploadDetail renders one upload's page (GET /uploads/{id}). The row is +// read through the user-scoped store, so another user's id is a 404 — existence is +// not disclosed, and the title never appears in the response. +func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) { + u, ok := a.loadOwnedUpload(w, r) + if !ok { + return + } + a.render(w, r, UploadPage(*u)) +} + +// handleUploadStatus renders the status fragment HTMX polls. Unfinished states +// keep polling; terminal states render without hx-trigger so the page stops +// asking (ADR-025: never a stuck spinner). +func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) { + u, ok := a.loadOwnedUpload(w, r) + if !ok { + return + } + a.render(w, r, uploadStatus(*u)) +} + +func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) { + userID, ok := a.currentUserID(w, r) + if !ok { + return nil, false + } + u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id")) + if errors.Is(err, store.ErrUploadNotFound) { + http.NotFound(w, r) + return nil, false + } + if err != nil { + a.serverError(w, r, "get upload", err) + return nil, false + } + return u, true +} + +// titleFromFilename is the default title: the file's name without its extension. +func titleFromFilename(name string) string { + base := filepath.Base(name) + t := strings.TrimSuffix(base, filepath.Ext(base)) + if t == "" || t == "." { + return "Untitled recording" + } + return clipRunes(t, maxTitleRunes) +} + +func clipRunes(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + return string([]rune(s)[:n]) +} + +// uploadFailureMessage turns a typed failure kind into words for the user. It is +// keyed on OUR kinds, never on a provider's error string (ADR-033). +func uploadFailureMessage(k domain.UploadErrorKind) string { + switch k { + case domain.UploadErrInterrupted: + return "The server restarted before this recording was transcribed. Please upload it again." + case domain.UploadErrTranscriptionFailed: + return "Transcription failed on every available service. Please upload it again later." + default: + return "This upload failed. Please upload it again." + } +} diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ new file mode 100644 index 0000000..ffcb759 --- /dev/null +++ b/internal/web/uploads.templ @@ -0,0 +1,91 @@ +package web + +import ( + "fmt" + "time" + + "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/uploads" +) + +// UploadsPage is the upload form plus the caller's own uploads (ADR-033). +templ UploadsPage(v uploadsView) { + @Layout("Tapir — Upload a recording") { + + } +} + +// UploadPage is one upload's page: its title and the polling status region. +templ UploadPage(u domain.UploadedTranscript) { + @Layout("Tapir — " + u.Title) { + + } +} + +// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the +// page re-asks; done and failed render without it, so nothing polls forever. +templ uploadStatus(u domain.UploadedTranscript) { + switch u.Status { + case domain.UploadDone: +
+

+ Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) } + of audio by { u.Endpoint }. +

+
+ Transcript +
{ u.Content }
+
+
+ case domain.UploadFailed: + + default: +
+ if u.Status == domain.UploadTranscribing { +

Transcribing… a long recording takes a few minutes.

+ } else { +

Queued — waiting for its turn to be transcribed.

+ } +
+ } +} diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go new file mode 100644 index 0000000..a874853 --- /dev/null +++ b/internal/web/uploads_templ.go @@ -0,0 +1,381 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package web + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "time" + + "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/uploads" +) + +// UploadsPage is the upload form plus the caller's own uploads (ADR-033). +func UploadsPage(v uploadsView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Upload a recording

Audio only. Up to ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 17, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " and ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(v.Limits.MaxDuration)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 50} + } + _, 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, 3, ". The transcript is private to you; the audio is deleted once it has been transcribed.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 22, Col: 57} + } + _, 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, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Rows) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Your recordings

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — Upload a recording").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// UploadPage is one upload's page: its title and the polling status region. +func UploadPage(u domain.UploadedTranscript) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 50, Col: 16} + } + _, 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, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = uploadStatus(u).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

All recordings

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the +// page re-asks; done and failed render without it, so nothing polls forever. +func uploadStatus(u domain.UploadedTranscript) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + switch u.Status { + case domain.UploadDone: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Transcribed ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 64, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " of audio by ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ".

Transcript
")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var15 string
+			templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 69, Col: 40}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case domain.UploadFailed: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 74, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + default: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if u.Status == domain.UploadTranscribing { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

Transcribing… a long recording takes a few minutes.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Queued — waiting for its turn to be transcribed.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate