feat(16-04): restyle files section with @ui.Table, EmptyState, and tr rows
- Replace <ul>/<li> layout with @ui.Table using fileTableHead/fileTableBody helpers - Add .overview-section-heading header with "Files" h3 and Upload file button - Convert FileListRow outer element from <li> to <tr class="file-row-zone"> - Convert FileDeleteConfirmFragment outer element from <div> to <tr class="file-row-zone"> with <td colspan="4"> - Add Download and Delete @ui.IconButton in FileListRow actions column - Replace FileListEmpty with @ui.EmptyState in FilesTabFragment and UploadErrorFragment - Convert FileRowGone from <div> to <tr> for DOM consistency - All 9 file handler tests pass; go build ./... exits 0
This commit is contained in:
parent
3a5a26c5c8
commit
ca693f1683
1 changed files with 156 additions and 96 deletions
|
|
@ -6,26 +6,57 @@ import (
|
|||
"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.
|
||||
// FilesTabFragment renders the files section with an overview-section layout,
|
||||
// an "Upload file" button, and either a table of files or an empty state.
|
||||
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 class="overview-section">
|
||||
<div class="overview-section-heading">
|
||||
<h3>Files</h3>
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Upload file",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + tablo.ID.String() + "/files/upload-form",
|
||||
"hx-target": "#file-upload-slot",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div id="file-upload-slot"></div>
|
||||
if len(files) == 0 {
|
||||
@ui.EmptyState(ui.EmptyStateProps{
|
||||
Title: "No files yet",
|
||||
Description: "Upload your first file to get started.",
|
||||
})
|
||||
} else {
|
||||
@ui.Table(ui.TableProps{
|
||||
Head: fileTableHead(),
|
||||
Body: fileTableBody(tablo.ID, files),
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// fileTableHead renders the <thead> row for the files table.
|
||||
templ fileTableHead() {
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
}
|
||||
|
||||
// fileTableBody renders the <tbody> rows for the files table.
|
||||
templ fileTableBody(tabloID uuid.UUID, files []sqlc.TabloFile) {
|
||||
for _, f := range files {
|
||||
@FileListRow(tabloID, f)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -66,112 +97,141 @@ templ FileUploadForm(tabloID uuid.UUID, csrfToken string, uploadErr string) {
|
|||
</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).
|
||||
// FileListRow renders a single file row as a <tr> for use inside @ui.Table.
|
||||
// HTMX outerHTML swap on .file-row-zone works because both FileListRow and
|
||||
// FileDeleteConfirmFragment use <tr class="file-row-zone">.
|
||||
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>
|
||||
<tr class="file-row-zone" id={ "file-" + file.ID.String() }>
|
||||
<td>{ file.Filename }</td>
|
||||
<td>{ formatBytes(file.SizeBytes) }</td>
|
||||
<td>
|
||||
if file.CreatedAt.Valid {
|
||||
{ file.CreatedAt.Time.Format("2006-01-02") }
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={ templ.SafeURL("/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/download") }
|
||||
aria-label={ "Download " + file.Filename }
|
||||
>
|
||||
@ui.IconButton(ui.IconButtonProps{
|
||||
Label: "Download file",
|
||||
Icon: "download",
|
||||
Variant: ui.IconButtonVariantNeutral,
|
||||
Tone: ui.IconButtonToneGhost,
|
||||
Type: "button",
|
||||
})
|
||||
</a>
|
||||
@ui.IconButton(ui.IconButtonProps{
|
||||
Label: "Delete file",
|
||||
Icon: "trash",
|
||||
Variant: ui.IconButtonVariantDanger,
|
||||
Tone: ui.IconButtonToneGhost,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete-confirm",
|
||||
"hx-target": "closest .file-row-zone",
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Outer element MUST be <tr class="file-row-zone"> to match FileListRow for HTMX outerHTML swap.
|
||||
// colspan="4" spans all 4 columns (Filename / Size / Uploaded / Actions).
|
||||
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)
|
||||
<tr class="file-row-zone" id={ "file-" + file.ID.String() }>
|
||||
<td colspan="4">
|
||||
<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: "Yes, delete",
|
||||
Variant: ui.ButtonVariantDanger,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Label: "Keep file",
|
||||
Variant: ui.ButtonVariantNeutral,
|
||||
Tone: ui.ButtonToneSoft,
|
||||
Size: ui.SizeMD,
|
||||
Type: "submit",
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"aria-label": "Confirm delete file",
|
||||
"hx-get": "/tablos/" + tabloID.String() + "/files",
|
||||
"hx-target": "#tab-content",
|
||||
"hx-swap": "innerHTML",
|
||||
"aria-label": "Keep 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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
// FileListEmpty renders the empty-state message when no files are attached.
|
||||
// Retained for backward compatibility; FilesTabFragment now uses @ui.EmptyState instead.
|
||||
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).
|
||||
// FileRowGone renders an empty zone tr with the file's id so HTMX outerHTML
|
||||
// swap removes the row from the DOM after a successful delete (FILE-05).
|
||||
// Same pattern as TaskCardGone.
|
||||
templ FileRowGone(fileID uuid.UUID) {
|
||||
<div id={ "file-" + fileID.String() } class="file-row-zone"></div>
|
||||
<tr id={ "file-" + fileID.String() } class="file-row-zone"></tr>
|
||||
}
|
||||
|
||||
// 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 class="overview-section">
|
||||
<div class="overview-section-heading">
|
||||
<h3>Files</h3>
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Upload file",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + tablo.ID.String() + "/files/upload-form",
|
||||
"hx-target": "#file-upload-slot",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div id="file-upload-slot">
|
||||
@FileUploadForm(tablo.ID, csrfToken, errMsg)
|
||||
</div>
|
||||
if len(files) == 0 {
|
||||
@ui.EmptyState(ui.EmptyStateProps{
|
||||
Title: "No files yet",
|
||||
Description: "Upload your first file to get started.",
|
||||
})
|
||||
} else {
|
||||
@ui.Table(ui.TableProps{
|
||||
Head: fileTableHead(),
|
||||
Body: fileTableBody(tablo.ID, files),
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue