feat(05-02): 3-tab layout + files templates + router + main.go S3 wiring
- 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
This commit is contained in:
parent
f50836fa31
commit
a12c5abea6
11 changed files with 280 additions and 18 deletions
|
|
@ -14,12 +14,14 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/auth"
|
"backend/internal/auth"
|
||||||
"backend/internal/db"
|
"backend/internal/db"
|
||||||
"backend/internal/db/sqlc"
|
"backend/internal/db/sqlc"
|
||||||
|
"backend/internal/files"
|
||||||
"backend/internal/web"
|
"backend/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -79,7 +81,39 @@ func main() {
|
||||||
tabloDeps := web.TablosDeps{Queries: q}
|
tabloDeps := web.TablosDeps{Queries: q}
|
||||||
taskDeps := web.TasksDeps{Queries: q}
|
taskDeps := web.TasksDeps{Queries: q}
|
||||||
|
|
||||||
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, csrfKey, env)
|
// S3 / files store (D-02: read env vars and pass to files.NewStore).
|
||||||
|
s3Endpoint := os.Getenv("S3_ENDPOINT")
|
||||||
|
s3Bucket := os.Getenv("S3_BUCKET")
|
||||||
|
s3AccessKey := os.Getenv("S3_ACCESS_KEY")
|
||||||
|
s3SecretKey := os.Getenv("S3_SECRET_KEY")
|
||||||
|
s3Region := os.Getenv("S3_REGION")
|
||||||
|
if s3Region == "" {
|
||||||
|
s3Region = "us-east-1"
|
||||||
|
}
|
||||||
|
s3UsePathStyle := os.Getenv("S3_USE_PATH_STYLE") == "true"
|
||||||
|
|
||||||
|
maxUploadMB := 25
|
||||||
|
if v := os.Getenv("MAX_UPLOAD_SIZE_MB"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
maxUploadMB = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filesStore web.FileStorer
|
||||||
|
if s3Endpoint != "" && s3Bucket != "" {
|
||||||
|
var fsErr error
|
||||||
|
filesStore, fsErr = files.NewStore(ctx, s3Endpoint, s3Bucket, s3Region, s3AccessKey, s3SecretKey, s3UsePathStyle)
|
||||||
|
if fsErr != nil {
|
||||||
|
slog.Error("s3 client init failed", "err", fsErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("S3_ENDPOINT or S3_BUCKET unset — file upload routes will return 503")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
|
||||||
|
|
||||||
|
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
csrfKey[i] = byte(i + 1)
|
csrfKey[i] = byte(i + 1)
|
||||||
}
|
}
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||||
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, csrfKey, "dev", "localhost")
|
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractCSRFToken performs a GET request and extracts the _csrf token from the
|
// extractCSRFToken performs a GET request and extracts the _csrf token from the
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,14 @@ var testCSRFKey = func() []byte {
|
||||||
// Referer header are accepted.
|
// Referer header are accepted.
|
||||||
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||||
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestRouterWithLimiter builds a router with an injected LimiterStore,
|
// newTestRouterWithLimiter builds a router with an injected LimiterStore,
|
||||||
// enabling rate-limit tests to use a fake clock.
|
// enabling rate-limit tests to use a fake clock.
|
||||||
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
|
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
|
||||||
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCSRFToken performs a GET request to path and extracts the CSRF token
|
// getCSRFToken performs a GET request to path and extracts the CSRF token
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc {
|
||||||
tasks = []sqlc.Task{}
|
tasks = []sqlc.Task{}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks).Render(r.Context(), w)
|
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview").Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc {
|
||||||
if tasks == nil {
|
if tasks == nil {
|
||||||
tasks = []sqlc.Task{}
|
tasks = []sqlc.Task{}
|
||||||
}
|
}
|
||||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks).Render(ctx, w)
|
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview").Render(ctx, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import (
|
||||||
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
tabloDeps := TablosDeps{Queries: q}
|
||||||
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginUser signs up a user and returns the session cookie set after signup.
|
// loginUser signs up a user and returns the session cookie set after signup.
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
tabloDeps := TablosDeps{Queries: q}
|
||||||
taskDeps := TasksDeps{Queries: q}
|
taskDeps := TasksDeps{Queries: q}
|
||||||
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, testCSRFKey, "dev", "localhost")
|
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- TestTasksKanbanRenders (TASK-01) ----
|
// ---- TestTasksKanbanRenders (TASK-01) ----
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ func TestHealthz_Down(t *testing.T) {
|
||||||
// was public. The HTMX demo content is tested by
|
// was public. The HTMX demo content is tested by
|
||||||
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
||||||
func TestIndex_UnauthRedirects(t *testing.T) {
|
func TestIndex_UnauthRedirects(t *testing.T) {
|
||||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev")
|
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ func TestIndex_UnauthRedirects(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDemoTime_Fragment(t *testing.T) {
|
func TestDemoTime_Fragment(t *testing.T) {
|
||||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev")
|
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/demo/time", nil)
|
req := httptest.NewRequest(http.MethodGet, "/demo/time", nil)
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ func TestDemoTime_Fragment(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestID_HeaderSet(t *testing.T) {
|
func TestRequestID_HeaderSet(t *testing.T) {
|
||||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev")
|
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ type Pinger interface {
|
||||||
// trustedOrigins is an optional list of additional origins for the CSRF
|
// trustedOrigins is an optional list of additional origins for the CSRF
|
||||||
// referer check (used in integration tests to allow localhost requests without
|
// referer check (used in integration tests to allow localhost requests without
|
||||||
// a Referer header). In production, pass no extra args — leave empty.
|
// a Referer header). In production, pass no extra args — leave empty.
|
||||||
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
|
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(RequestIDMiddleware)
|
r.Use(RequestIDMiddleware)
|
||||||
r.Use(chimw.RealIP)
|
r.Use(chimw.RealIP)
|
||||||
|
|
@ -90,6 +90,8 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD
|
||||||
r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
|
r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
|
||||||
r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
|
r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
|
||||||
r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
|
r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
|
||||||
|
// Tasks tab entry point — must be BEFORE static task sub-routes (Pitfall 1).
|
||||||
|
r.Get("/tablos/{id}/tasks", TabloTasksTabHandler(fileDeps))
|
||||||
// Task routes — static segments BEFORE parametric (Pitfall 1).
|
// Task routes — static segments BEFORE parametric (Pitfall 1).
|
||||||
// /tablos/{id}/tasks/new and /tablos/{id}/tasks/cancel-new are static
|
// /tablos/{id}/tasks/new and /tablos/{id}/tasks/cancel-new are static
|
||||||
// segments relative to /tablos/{id}/tasks/* and must come first.
|
// segments relative to /tablos/{id}/tasks/* and must come first.
|
||||||
|
|
@ -103,6 +105,13 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD
|
||||||
r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps))
|
r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps))
|
||||||
r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps))
|
r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps))
|
||||||
r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps))
|
r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps))
|
||||||
|
// File routes — static segments BEFORE parametric (Pitfall 6 in RESEARCH).
|
||||||
|
r.Get("/tablos/{id}/files", TabloFilesTabHandler(fileDeps))
|
||||||
|
r.Post("/tablos/{id}/files", FileUploadHandler(fileDeps))
|
||||||
|
// Parametric file routes — AFTER static file segment.
|
||||||
|
r.Get("/tablos/{id}/files/{file_id}/download", FileDownloadHandler(fileDeps))
|
||||||
|
r.Get("/tablos/{id}/files/{file_id}/delete-confirm", FileDeleteConfirmHandler(fileDeps))
|
||||||
|
r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps))
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Get("/healthz", HealthzHandler(pinger))
|
r.Get("/healthz", HealthzHandler(pinger))
|
||||||
|
|
|
||||||
130
backend/templates/files.templ
Normal file
130
backend/templates/files.templ
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
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>
|
||||||
|
}
|
||||||
23
backend/templates/files_helpers.go
Normal file
23
backend/templates/files_helpers.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// formatBytes converts a byte count to a human-readable string.
|
||||||
|
// Examples: 512 → "512 B", 1536 → "1.5 KB", 3670016 → "3.5 MB".
|
||||||
|
func formatBytes(n int64) string {
|
||||||
|
const (
|
||||||
|
kb = 1024
|
||||||
|
mb = 1024 * kb
|
||||||
|
gb = 1024 * mb
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case n < kb:
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
case n < mb:
|
||||||
|
return fmt.Sprintf("%.1f KB", float64(n)/kb)
|
||||||
|
case n < gb:
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(n)/mb)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.1f GB", float64(n)/gb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -166,11 +166,12 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
|
||||||
<div id="create-form-slot" hx-swap-oob="true"></div>
|
<div id="create-form-slot" hx-swap-oob="true"></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// TabloDetailPage renders the full detail page for a single tablo.
|
// TabloDetailPage renders the full detail page for a single tablo with a 3-tab layout.
|
||||||
// Includes title zone, description zone, delete zone, and the kanban board.
|
// Tabs: Overview / Tasks / Files. activeTab selects the initially rendered tab content.
|
||||||
|
// files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs).
|
||||||
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
|
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
|
||||||
// tasks is the pre-fetched list of tasks for this tablo (may be empty slice).
|
// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url.
|
||||||
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task) {
|
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, files []sqlc.TabloFile, activeTab string) {
|
||||||
@Layout("Tablos — Xtablo", user, csrfToken) {
|
@Layout("Tablos — Xtablo", user, csrfToken) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<a href="/" class="text-sm text-slate-600 hover:underline">← Back to tablos</a>
|
<a href="/" class="text-sm text-slate-600 hover:underline">← Back to tablos</a>
|
||||||
|
|
@ -184,12 +185,77 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
|
||||||
<div class="tablo-delete-zone">
|
<div class="tablo-delete-zone">
|
||||||
@TabloDeleteButtonFragment(tablo, csrfToken)
|
@TabloDeleteButtonFragment(tablo, csrfToken)
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<!-- Tab navigation bar (D-07, D-08) -->
|
||||||
@KanbanBoard(tablo.ID, csrfToken, tasks)
|
<nav class="mt-8 flex gap-1 border-b border-slate-200">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
|
||||||
|
hx-get={ "/tablos/" + tablo.ID.String() }
|
||||||
|
hx-target="#tab-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url={ "/tablos/" + tablo.ID.String() }
|
||||||
|
if activeTab == "overview" || activeTab == "" {
|
||||||
|
class="px-4 py-2 text-sm font-semibold border-b-2 border-slate-800 text-slate-800 -mb-px"
|
||||||
|
} else {
|
||||||
|
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
|
||||||
|
}
|
||||||
|
>Overview</a>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/tasks") }
|
||||||
|
hx-get={ "/tablos/" + tablo.ID.String() + "/tasks" }
|
||||||
|
hx-target="#tab-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/tasks" }
|
||||||
|
if activeTab == "tasks" {
|
||||||
|
class="px-4 py-2 text-sm font-semibold border-b-2 border-slate-800 text-slate-800 -mb-px"
|
||||||
|
} else {
|
||||||
|
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
|
||||||
|
}
|
||||||
|
>Tasks</a>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/files") }
|
||||||
|
hx-get={ "/tablos/" + tablo.ID.String() + "/files" }
|
||||||
|
hx-target="#tab-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/files" }
|
||||||
|
if activeTab == "files" {
|
||||||
|
class="px-4 py-2 text-sm font-semibold border-b-2 border-slate-800 text-slate-800 -mb-px"
|
||||||
|
} else {
|
||||||
|
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
|
||||||
|
}
|
||||||
|
>Files</a>
|
||||||
|
</nav>
|
||||||
|
<!-- Tab content area — HTMX tab switches target this div -->
|
||||||
|
<div id="tab-content" class="mt-6">
|
||||||
|
if activeTab == "tasks" {
|
||||||
|
@TasksTabFragment(tablo, tasks, csrfToken)
|
||||||
|
} else if activeTab == "files" {
|
||||||
|
@FilesTabFragment(tablo, files, csrfToken)
|
||||||
|
} else {
|
||||||
|
@TabloOverviewTabFragment(tablo, csrfToken)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TabloOverviewTabFragment renders the overview tab content — description display.
|
||||||
|
// Returned as a standalone fragment for HTMX tab-switch responses.
|
||||||
|
templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) {
|
||||||
|
<div class="overview-tab">
|
||||||
|
if tablo.Description.Valid && tablo.Description.String != "" {
|
||||||
|
<p class="text-base text-slate-600">{ tablo.Description.String }</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm text-slate-400 italic">No description.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksTabFragment wraps the KanbanBoard for use as a standalone HTMX tab fragment.
|
||||||
|
// Returned by TabloTasksTabHandler on HX-Request == "true".
|
||||||
|
// Lives in tablos.templ (tablo-level concern) per plan D-07.
|
||||||
|
templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, csrfToken string) {
|
||||||
|
@KanbanBoard(tablo.ID, csrfToken, tasks)
|
||||||
|
}
|
||||||
|
|
||||||
// TabloTitleDisplay renders the tablo title as a clickable element that swaps
|
// TabloTitleDisplay renders the tablo title as a clickable element that swaps
|
||||||
// to the edit form on click. The outermost element carries class tablo-title-zone
|
// to the edit form on click. The outermost element carries class tablo-title-zone
|
||||||
// so hx-target="closest .tablo-title-zone" + hx-swap="outerHTML" round-trips cleanly.
|
// so hx-target="closest .tablo-title-zone" + hx-swap="outerHTML" round-trips cleanly.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue