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") {
+
+ Upload a recording
+
+ Audio only. Up to { fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20) } and
+ { uploads.FormatDuration(v.Limits.MaxDuration) }. The transcript is private to you;
+ the audio is deleted once it has been transcribed.
+
+ if v.Error != "" {
+ { v.Error }
+ }
+
+ if len(v.Rows) > 0 {
+
+ Your recordings
+
+ for _, u := range v.Rows {
+ -
+ { u.Title }
+ { string(u.Status) }
+
+ }
+
+
+ }
+
+ }
+}
+
+// UploadPage is one upload's page: its title and the polling status region.
+templ UploadPage(u domain.UploadedTranscript) {
+ @Layout("Tapir — " + u.Title) {
+
+ { u.Title }
+ @uploadStatus(u)
+ All recordings
+
+ }
+}
+
+// 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:
+
+
{ uploadFailureMessage(u.ErrorKind) }
+
+ 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
+ }
+ for _, u := range v.Rows {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "- ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, 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: 35, Col: 63}
+ }
+ _, 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, 10, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Status))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 45}
+ }
+ _, 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, 11, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ 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