feat(12-02): implement discussion unread badges
This commit is contained in:
parent
e3c8d51782
commit
3111b6e011
4 changed files with 96 additions and 19 deletions
|
|
@ -4,6 +4,32 @@ FROM tablos
|
|||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListTablosByUserWithDiscussionUnread :many
|
||||
SELECT tablos.id,
|
||||
tablos.user_id,
|
||||
tablos.title,
|
||||
tablos.description,
|
||||
tablos.color,
|
||||
tablos.created_at,
|
||||
tablos.updated_at,
|
||||
COALESCE(count(unread_messages.id), 0)::bigint AS discussion_unread_count
|
||||
FROM tablos
|
||||
LEFT JOIN discussion_read_states AS read_state
|
||||
ON read_state.tablo_id = tablos.id
|
||||
AND read_state.user_id = sqlc.arg(user_id)
|
||||
LEFT JOIN discussion_messages AS last_read_message
|
||||
ON last_read_message.id = read_state.last_read_message_id
|
||||
LEFT JOIN discussion_messages AS unread_messages
|
||||
ON unread_messages.tablo_id = tablos.id
|
||||
AND (
|
||||
read_state.last_read_message_id IS NULL
|
||||
OR last_read_message.id IS NULL
|
||||
OR unread_messages.created_at > last_read_message.created_at
|
||||
)
|
||||
WHERE tablos.user_id = sqlc.arg(user_id)
|
||||
GROUP BY tablos.id, tablos.user_id, tablos.title, tablos.description, tablos.color, tablos.created_at, tablos.updated_at
|
||||
ORDER BY tablos.created_at DESC;
|
||||
|
||||
-- name: GetTabloByID :one
|
||||
SELECT id, user_id, title, description, color, created_at, updated_at
|
||||
FROM tablos
|
||||
|
|
|
|||
|
|
@ -40,18 +40,18 @@ func TablosListHandler(deps TablosDeps) http.HandlerFunc {
|
|||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
|
||||
tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
||||
tabloRows, err := deps.Queries.ListTablosByUserWithDiscussionUnread(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
slog.Default().Error("tablos list: query failed", "user_id", user.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if tablos == nil {
|
||||
tablos = []sqlc.Tablo{}
|
||||
if tabloRows == nil {
|
||||
tabloRows = []sqlc.ListTablosByUserWithDiscussionUnreadRow{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
|
||||
_ = templates.TablosDashboard(user, csrf.Token(r), templates.TabloCardsFromUnreadRows(tabloRows)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -422,13 +422,13 @@ func renderTabloCreateError(w http.ResponseWriter, r *http.Request, form templat
|
|||
// Non-HTMX: render full dashboard with errs embedded in the form.
|
||||
// Fetch the user's tablos so the list is still accurate on re-render.
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
tablos, fetchErr := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
||||
tabloRows, fetchErr := deps.Queries.ListTablosByUserWithDiscussionUnread(r.Context(), user.ID)
|
||||
if fetchErr != nil {
|
||||
slog.Default().Error("renderTabloCreateError: list fetch failed", "user_id", user.ID, "err", fetchErr)
|
||||
tablos = []sqlc.Tablo{}
|
||||
tabloRows = []sqlc.ListTablosByUserWithDiscussionUnreadRow{}
|
||||
}
|
||||
// Render full page — form fragment is not embedded in the full page by default;
|
||||
// for the non-HTMX error case we redirect so the user sees their list intact
|
||||
// and can try again (simpler than threading form state through the full page).
|
||||
_ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
|
||||
_ = templates.TablosDashboard(user, csrf.Token(r), templates.TabloCardsFromUnreadRows(tabloRows)).Render(r.Context(), w)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ type DiscussionTabData struct {
|
|||
Messages []DiscussionMessageView
|
||||
}
|
||||
|
||||
type TabloCardView struct {
|
||||
Tablo sqlc.Tablo
|
||||
DiscussionUnreadCount int64
|
||||
}
|
||||
|
||||
func DiscussionPostURL(tabloID uuid.UUID) string {
|
||||
return "/tablos/" + tabloID.String() + "/discussion/messages"
|
||||
}
|
||||
|
|
@ -65,6 +70,43 @@ func DiscussionMessageFromRow(row sqlc.GetDiscussionMessageWithAuthorRow) Discus
|
|||
}
|
||||
}
|
||||
|
||||
func TabloCardsFromUnreadRows(rows []sqlc.ListTablosByUserWithDiscussionUnreadRow) []TabloCardView {
|
||||
cards := make([]TabloCardView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cards = append(cards, TabloCardView{
|
||||
Tablo: sqlc.Tablo{
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
Color: row.Color,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
},
|
||||
DiscussionUnreadCount: row.DiscussionUnreadCount,
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func TabloCardFromTablo(tablo sqlc.Tablo) TabloCardView {
|
||||
return TabloCardView{Tablo: tablo}
|
||||
}
|
||||
|
||||
func DiscussionUnreadDisplay(count int64) string {
|
||||
if count > 99 {
|
||||
return "99+"
|
||||
}
|
||||
return strconv.FormatInt(count, 10)
|
||||
}
|
||||
|
||||
func DiscussionUnreadAriaLabel(count int64) string {
|
||||
if count == 1 {
|
||||
return "1 unread discussion message"
|
||||
}
|
||||
return strconv.FormatInt(count, 10) + " unread discussion messages"
|
||||
}
|
||||
|
||||
func DiscussionDateLabel(t time.Time) string {
|
||||
return t.Local().Format("January 2, 2006")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
// TablosDashboard renders the root authenticated dashboard: heading, "New tablo"
|
||||
// button, create-form slot, and the list of tablo cards (or empty state).
|
||||
// UI-SPEC §1 Interaction Contract — GET /.
|
||||
templ TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) {
|
||||
templ TablosDashboard(user *auth.User, csrfToken string, tablos []TabloCardView) {
|
||||
@Layout("Tablos — Xtablo", user, csrfToken) {
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-[28px] font-semibold leading-tight">Your Tablos</h1>
|
||||
|
|
@ -66,32 +66,41 @@ templ TablosEmptyState() {
|
|||
// TabloCard renders a single tablo as a ui.Card on the dashboard.
|
||||
// Guards description and color rendering against null pgtype.Text values (Pitfall 6).
|
||||
// Delegates delete-zone rendering to TabloDeleteButtonFragment (single source of truth).
|
||||
templ TabloCard(tablo sqlc.Tablo, csrfToken string) {
|
||||
@ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) {
|
||||
templ TabloCard(card TabloCardView, csrfToken string) {
|
||||
@ui.Card(templ.Attributes{"id": "tablo-" + card.Tablo.ID.String()}) {
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold leading-snug">{ tablo.Title }</h2>
|
||||
if tablo.Description.Valid && tablo.Description.String != "" {
|
||||
<p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-xl font-semibold leading-snug">{ card.Tablo.Title }</h2>
|
||||
@DiscussionUnreadBadge(card.DiscussionUnreadCount)
|
||||
</div>
|
||||
if card.Tablo.Description.Valid && card.Tablo.Description.String != "" {
|
||||
<p class="mt-2 text-base text-slate-600">{ card.Tablo.Description.String }</p>
|
||||
}
|
||||
if tablo.Color.Valid && tablo.Color.String != "" {
|
||||
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||
style={ "background-color: " + tablo.Color.String }
|
||||
style={ "background-color: " + card.Tablo.Color.String }
|
||||
></span>
|
||||
<span class="text-sm text-slate-500">{ tablo.Color.String }</span>
|
||||
<span class="text-sm text-slate-500">{ card.Tablo.Color.String }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@TabloDeleteButtonFragment(tablo, csrfToken)
|
||||
@TabloDeleteButtonFragment(card.Tablo, csrfToken)
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
|
||||
<a href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ DiscussionUnreadBadge(count int64) {
|
||||
if count > 0 {
|
||||
<span class="inline-flex min-w-6 items-center justify-center rounded-full border border-blue-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold leading-none text-blue-700" aria-label={ DiscussionUnreadAriaLabel(count) }>{ DiscussionUnreadDisplay(count) }</span>
|
||||
}
|
||||
}
|
||||
|
||||
// TabloCreateFormFragment renders the inline create form loaded into #create-form-slot
|
||||
// via HTMX. Falls back to a plain POST /tablos for non-JS paths.
|
||||
// UI-SPEC §2 Interaction Contract — GET /tablos/new + POST /tablos.
|
||||
|
|
@ -162,7 +171,7 @@ templ TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrf
|
|||
// HTMX applies the primary swap (HX-Retarget: #tablos-list, afterbegin) AND
|
||||
// the OOB swap (#create-form-slot → empty) from a single response.
|
||||
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
|
||||
@TabloCard(tablo, csrfToken)
|
||||
@TabloCard(TabloCardFromTablo(tablo), csrfToken)
|
||||
<div id="create-form-slot" hx-swap-oob="true"></div>
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue