xtablo-source/backend/templates/files.templ
Arthur Belleville 9d4dd4f3e2
feat(05-03): implement FileDownloadHandler, FileDeleteConfirmHandler, FileDeleteHandler
- FileDownloadHandler: nil guard → loadOwnedTabloForFile → PresignDownload → 302 redirect (FILE-04)
- FileDeleteConfirmHandler: nil guard → loadOwnedTabloForFile → render FileDeleteConfirmFragment
- FileDeleteHandler: nil guard → loadOwnedTabloForFile → S3 Delete (log+continue) → DeleteTabloFile → FileRowGone HTMX / 303 redirect (FILE-05, FILE-06)
- Add FileDeleteConfirmFragment templ component mirroring TaskDeleteConfirmFragment pattern (T-05-03-05)
2026-05-15 12:34:07 +02:00

177 lines
6 KiB
Text

package templates
import (
"backend/internal/db/sqlc"
"backend/internal/web/ui"
"github.com/google/uuid"
)
// FilesTabFragment renders the upload form followed by the file list.
// Called by TabloFilesTabHandler for HTMX requests and embedded in TabloDetailPage
// #tab-content for the "files" tab.
templ FilesTabFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string) {
<div class="files-tab">
@FileUploadForm(tablo.ID, csrfToken, "")
<div class="mt-6">
if len(files) == 0 {
@FileListEmpty()
} else {
<ul class="divide-y divide-slate-200 rounded border border-slate-200">
for _, f := range files {
@FileListRow(tablo.ID, f)
}
</ul>
}
</div>
</div>
}
// FileUploadForm renders the multipart upload form.
// hx-encoding="multipart/form-data" is required for HTMX multipart submissions.
// If uploadErr is non-empty, a red error message is shown above the form.
templ FileUploadForm(tabloID uuid.UUID, csrfToken string, uploadErr string) {
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/files") }
enctype="multipart/form-data"
hx-post={ "/tablos/" + tabloID.String() + "/files" }
hx-encoding="multipart/form-data"
hx-target="#tab-content"
hx-swap="innerHTML"
class="space-y-3 rounded border border-slate-200 bg-slate-50 p-4"
>
@ui.CSRFField(csrfToken)
if uploadErr != "" {
<div class="rounded bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700">
{ uploadErr }
</div>
}
<div>
<label for="file" class="block text-sm font-medium text-slate-700">Attach a file</label>
<input
id="file"
type="file"
name="file"
required
class="mt-1 block w-full text-sm text-slate-700 file:mr-3 file:rounded file:border-0 file:bg-slate-200 file:px-3 file:py-1 file:text-sm file:font-medium hover:file:bg-slate-300"
/>
</div>
@ui.Button(ui.ButtonProps{
Label: "Upload",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</form>
}
// FileListRow renders a single file row showing filename, human-readable size, and date.
// Download link points to GET /tablos/{tabloID}/files/{file.ID}/download (wired in Plan 03).
// Delete confirm link uses HTMX outerHTML swap on .file-row-zone (Plan 03).
templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) {
<li class="file-row-zone" id={ "file-" + file.ID.String() }>
<div class="flex items-center justify-between px-4 py-3">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-800 truncate">{ file.Filename }</p>
<p class="text-xs text-slate-500 mt-0.5">
{ formatBytes(file.SizeBytes) }
if file.CreatedAt.Valid {
<span class="ml-2">{ file.CreatedAt.Time.Format("2006-01-02 15:04") }</span>
}
</p>
</div>
<div class="flex items-center gap-3 ml-4">
<a
href={ templ.SafeURL("/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/download") }
class="text-sm text-blue-600 hover:underline"
>Download</a>
<button
type="button"
class="text-sm text-red-600 hover:underline"
hx-get={ "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete-confirm" }
hx-target="closest .file-row-zone"
hx-swap="outerHTML"
>Delete</button>
</div>
</div>
</li>
}
// FileDeleteConfirmFragment renders the inline delete confirmation for a single file row.
// Mirrors TaskDeleteConfirmFragment exactly: outer wrapper reuses the .file-row-zone id
// so HTMX outerHTML swap replaces the row in place (T-05-03-05, FILE-05).
templ FileDeleteConfirmFragment(tabloID uuid.UUID, file sqlc.TabloFile, csrfToken string) {
<div class="file-row-zone" id={ "file-" + file.ID.String() }>
<div class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2">
<p class="text-sm font-semibold text-slate-800">Delete file?</p>
<p class="text-xs text-slate-600 truncate">{ file.Filename }</p>
<p class="text-xs text-slate-500">This cannot be undone.</p>
<div class="flex items-center gap-2">
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete") }
hx-post={ "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete" }
hx-target="closest .file-row-zone"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
@ui.Button(ui.ButtonProps{
Label: "Yes, delete",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
Attrs: templ.Attributes{
"aria-label": "Confirm delete file",
},
})
</form>
@ui.Button(ui.ButtonProps{
Label: "Keep file",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/files",
"hx-target": "#tab-content",
"hx-swap": "innerHTML",
"aria-label": "Keep file",
},
})
</div>
</div>
</div>
}
// FileListEmpty renders the empty-state message when no files are attached.
templ FileListEmpty() {
<p class="text-sm text-slate-400 italic py-4">No files attached yet.</p>
}
// FileRowGone renders an empty zone div with the file's id so HTMX outerHTML
// swap removes the row from the DOM after a successful delete (Plan 03, FILE-05).
// Same pattern as TaskCardGone.
templ FileRowGone(fileID uuid.UUID) {
<div id={ "file-" + fileID.String() } class="file-row-zone"></div>
}
// UploadErrorFragment re-renders FilesTabFragment with the error message set.
// Used by FileUploadHandler on size violation (returns 422).
templ UploadErrorFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string, errMsg string) {
<div class="files-tab">
@FileUploadForm(tablo.ID, csrfToken, errMsg)
<div class="mt-6">
if len(files) == 0 {
@FileListEmpty()
} else {
<ul class="divide-y divide-slate-200 rounded border border-slate-200">
for _, f := range files {
@FileListRow(tablo.ID, f)
}
</ul>
}
</div>
</div>
}