package web
import (
"fmt"
"strconv"
"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, chatEnabled bool, now time.Time) {
@Layout("Tapir — " + u.Title) {
{ u.Title }
Language: { languageLabel(u.Language) }
@uploadStatus(u, chatEnabled, now)
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, chatEnabled bool, now time.Time) {
switch u.Status {
case domain.UploadDone:
@videoNotice(u.HadVideo)
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by { u.Endpoint }.
if provider, cloud := cloudProvider(u.Endpoint); cloud {
This recording's audio left the homelab: it was transcribed by a cloud service ({ provider })
because the local engine could not.
}
@reconciliationNote(u.Reconciliation)
if len(u.Progress) > 0 {
What was done
@progressSteps(u.Progress, now, false)
}
if chatEnabled {
Ask about this recording
}
Transcript
{ u.Content }
@deleteRecording(u.ID)
case domain.UploadFailed:
@videoNotice(u.HadVideo)
{ uploadFailureMessage(u.ErrorKind) }
if len(u.Progress) > 0 {
Where it stopped
@progressSteps(u.Progress, now, false)
}
@deleteRecording(u.ID)
default:
@videoNotice(u.HadVideo)
if u.Status == domain.UploadTranscribing {
{{ health, silent := uploadHealth(u, now) }}
if len(u.Progress) > 0 {
@progressSteps(u.Progress, now, health != healthLost)
@healthNote(health, silent)
} else {
Transcribing… a long recording takes a few minutes.
}
} else {
Queued — waiting for its turn to be transcribed.
}
}
}
// deleteRecording is the confirm-then-delete control, shown only once a recording has
// finished (done or failed): deleting one that is still being processed is refused.
templ deleteRecording(id string) {
Delete this recording…
This permanently deletes the recording and its transcript. Are you sure?
}
// UploadChatPage is the no-JS full page for chatting with an uploaded transcript:
// the same chat panel as a video's (ADR-027), scoped to this recording.
templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
@Layout("Tapir — Ask about " + u.Title) {
{ u.Title }
Ask about this recording
Answers come only from this recording's transcript, which is private to you.
@chatPanel(v)
Back to the transcript
}
}
// videoNotice tells the user a video track was present and what became of it. A
// Teams meeting with the camera off is still a video file; it is accepted, only the
// audio is transcribed, and the picture is thrown away — so say so, in every state,
// instead of leaving the user to wonder (ADR-033).
templ videoNotice(hadVideo bool) {
if hadVideo {
This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
}
}
// reconciliationNote says what was done when two engines transcribed the audio
// (ADR-034), and never claims a second opinion that was not there: nil means a single
// engine and renders nothing. Spots the model changed or was unsure about are listed
// with a minute mark and both readings so the user can check them against the audio.
templ reconciliationNote(r *domain.Reconciliation) {
if r != nil {
switch r.Status {
case domain.ReconcileDone:
Reconciled from two transcripts ({ r.Base } and { r.Alt }):
{ strconv.Itoa(r.Disputes) } differences,
{ strconv.Itoa(r.AutoResolved) } settled automatically,
{ strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
{ strconv.Itoa(r.Unsure) } uncertain.
if len(shownSpots(r)) > 0 {
Spots to check against the audio
for _, c := range shownSpots(r) {
-
{ minuteMark(c.Seconds) }:
if c.Before != "" {
…{ c.Before }
}
{ c.Base } / { c.Alt } —
if c.Unsure {
uncertain kept { c.Used }
} else {
changed used { c.Used }
}
}
if moreSpots(r) > 0 {
and { strconv.Itoa(moreSpots(r)) } more not listed
}
}
case domain.ReconcileSingle:
Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.
case domain.ReconcileSkipped:
Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.
}
}
}
// progressSteps is the job's phases as a log that fills in as it runs: finished ones
// collapse to a line with how long they took, the running one has a spinner and its
// elapsed time, and what has not started is dimmed. live is false when the spinner would
// be a lie (the service is lost, or the job is over), so the running phase shows a
// warning mark instead. Only a counted phase gets a bar (ADR-035).
templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
for _, s := range steps {
-
if s.State == domain.StepRunning && live {
} else if s.State == domain.StepRunning {
!
} else {
{ stepMark(s) }
}
{ s.Label }
if t := stepTime(s, now); t != "" {
{ t }
}
if s.Note != "" {
{ s.Note }
}
if s.State == domain.StepRunning && s.Total > 0 {
{ strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }
}
}
}
// healthNote says so when the job is not simply running: the service stopped reporting,
// or a counted phase has gone quiet. Running says nothing: the spinner does.
templ healthNote(h jobHealth, silent time.Duration) {
switch h {
case healthLost:
The service stopped reporting { minutesAgo(silent) } ago. It may have restarted or crashed.
This page keeps checking; if nothing changes, upload the recording again.
case healthQuiet:
Nothing new for { minutesAgo(silent) }: the model may be slow. The service is still running,
and the job gives up on its own if it takes too long.
}
}