- tablos.templ: TabloDetailPage gains files+activeTab params, 3-tab nav with hx-push-url
- tablos.templ: TabloOverviewTabFragment + TasksTabFragment (wraps KanbanBoard) added
- files.templ: FilesTabFragment, FileUploadForm (hx-encoding=multipart/form-data),
FileListRow, FileListEmpty, FileRowGone, UploadErrorFragment
- files_helpers.go: formatBytes() converts int64 bytes to human-readable string
- router.go: fileDeps FilesDeps param added; TabloTasksTabHandler + file routes wired
- handlers_tablos.go: both TabloDetailPage call sites updated (nil, 'overview')
- main.go: S3_ENDPOINT/S3_BUCKET/S3_REGION env vars read; files.NewStore constructed;
fileDeps wired; nil filesStore allowed when S3 env unset (503 from handlers)
- All test routers updated to pass FilesDeps{} in new param position
130 lines
4.3 KiB
Text
130 lines
4.3 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>
|
|
}
|
|
|
|
// 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>
|
|
}
|