Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview/pages: multicolumn layout for notifs, group by timeframe

working atop the previous backend changes, this changeset adds the runes
to implement a 2-column layout in notifs (merged single column on
mobile), with grouping by "today", "this week" and "older".

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
Lewis
date (May 29, 2026, 2:50 PM +0300) commit d87c6fb7 parent 60d5173e change-id tkkmkkrm
+167 -63
+18 -6
appview/pages/templates/notifications/fragments/item.html
··· 1 1 {{define "notifications/fragments/item"}} 2 - <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 2 + <a href="{{ template "notificationUrl" . }}" 3 + onclick="navigator.sendBeacon('/notifications/{{ .ID }}/read')" 4 + class="block no-underline hover:no-underline"> 3 5 <div 4 6 class=" 5 - w-full mx-auto dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 6 - grid grid-cols-[auto_1fr_auto] gap-x-3 gap-y-1 items-center 7 + w-full mx-auto dark:text-white px-2 md:px-6 py-4 8 + {{ if not .Read }}bg-indigo-50/50 dark:bg-indigo-950/20 hover:bg-indigo-50 dark:hover:bg-indigo-950/50{{ else }}bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700/30{{ end }} 9 + transition-colors duration-150 10 + grid grid-cols-[auto_1fr_auto] gap-x-4 gap-y-1 items-center 7 11 "> 8 12 {{/* row 1, col 1: icon */}} 9 13 <div class="row-start-1 {{ template "notificationIconColor" . }}">{{ i (.Icon) "size-4" }}</div> 10 14 {{/* row 1, col 2: header */}} 11 15 <div class="row-start-1 flex items-center gap-1.5 text-sm min-w-0 overflow-hidden"> 12 16 {{- if not .Read }} 13 - {{ template "repo/fragments/colorBall" (dict "color" "#1e40af" "classes" "shrink-0 dark:hidden") }} 14 - {{ template "repo/fragments/colorBall" (dict "color" "#3b82f6" "classes" "shrink-0 hidden dark:block") }} 17 + {{ template "repo/fragments/colorBall" (dict "color" "#4338ca" "classes" "shrink-0 dark:hidden") }} 18 + {{ template "repo/fragments/colorBall" (dict "color" "#6366f1" "classes" "shrink-0 hidden dark:block") }} 15 19 {{- end -}} 16 20 {{- template "notificationHeader" . -}} 17 21 </div> ··· 25 29 </a> 26 30 {{end}} 27 31 28 - {{ define "notificationIconColor" }}{{ if eq .Type "issue_created" }}text-green-600 dark:text-green-500{{ else if eq .Type "issue_reopen" }}text-green-600 dark:text-green-500{{ else if eq .Type "pull_created" }}text-green-600 dark:text-green-500{{ else if eq .Type "pull_reopen" }}text-green-600 dark:text-green-500{{ else if eq .Type "pull_merged" }}text-purple-600 dark:text-purple-500{{ else if eq .Type "pull_closed" }}text-red-600 dark:text-red-500{{ else }}text-gray-500 dark:text-gray-400{{ end }}{{ end }} 32 + {{ define "notificationIconColor" }} 33 + {{ if or (eq .Type "issue_created") (eq .Type "issue_reopen") }}text-green-600 dark:text-green-500 34 + {{ else if or (eq .Type "pull_created") (eq .Type "pull_reopen") }}text-green-600 dark:text-green-500 35 + {{ else if eq .Type "pull_merged" }}text-purple-600 dark:text-purple-500 36 + {{ else if eq .Type "pull_closed" }}text-red-600 dark:text-red-500 37 + {{ else if eq .Type "user_mentioned" }}text-blue-600 dark:text-blue-500 38 + {{ else }}text-gray-500 dark:text-gray-400 39 + {{ end }} 40 + {{ end }} 29 41 30 42 {{ define "notificationNumber" }} 31 43 {{ if .Issue }}
+55 -8
appview/pages/templates/notifications/fragments/preview.html
··· 1 1 {{ define "notifications/fragments/preview" }} 2 + <div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex-wrap gap-2"> 3 + {{ $read := .ReadFilter }} 4 + {{ $cat := .CategoryFilter }} 5 + 6 + <div class="flex items-center gap-2 flex-wrap"> 7 + <div class="btn-group"> 8 + <a href="#" 9 + hx-get="/notifications/preview?read=inbox&category={{ $cat }}" 10 + hx-target="#notif-popup" 11 + hx-swap="innerHTML" 12 + class="btn-group-item {{ if eq $read "inbox" }}active{{ end }}"> 13 + {{ i "inbox" "size-4" }} Inbox 14 + </a> 15 + <a href="#" 16 + hx-get="/notifications/preview?read=unread&category={{ $cat }}" 17 + hx-target="#notif-popup" 18 + hx-swap="innerHTML" 19 + class="btn-group-item {{ if eq $read "unread" }}active{{ end }}"> 20 + {{ i "glasses" "size-4" }} Unread 21 + </a> 22 + </div> 23 + <button 24 + hx-post="/notifications/read-all" 25 + hx-swap="none" 26 + hx-on::after-request="htmx.ajax('GET', '/notifications/preview?read=unread&category={{ $cat }}', {target:'#notif-popup', swap:'innerHTML'}); htmx.ajax('GET', '/notifications/count', {target:'#notification-count-desktop', swap:'innerHTML'})" 27 + class="btn-flat text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 flex items-center gap-2 text-sm" 28 + title="Mark all read"> 29 + {{ i "check-check" "size-4" }} Mark all read 30 + </button> 31 + </div> 32 + 33 + <div class="btn-group"> 34 + {{ range $k, $label := list "all" "work" "social" }} 35 + <a href="#" 36 + hx-get="/notifications/preview?read={{ $read }}&category={{ $label }}" 37 + hx-target="#notif-popup" 38 + hx-swap="innerHTML" 39 + class="btn-group-item {{ if eq $label $cat }}active{{ end }} capitalize"> 40 + {{ $label }} 41 + </a> 42 + {{ end }} 43 + </div> 44 + 45 + </div> 46 + 2 47 {{ if .Notifications }} 3 48 <div class="divide-y divide-gray-200 dark:divide-gray-700 bg-slate-100 dark:bg-gray-900"> 4 49 {{ range .Notifications }} ··· 6 51 {{ end }} 7 52 </div> 8 53 {{ else }} 9 - <div class="px-4 py-8 text-sm text-center text-gray-400 dark:text-gray-500"> 10 - No notifications 54 + <div class="px-4 py-24 text-sm text-center text-gray-400 dark:text-gray-500"> 55 + <div class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600"> 56 + {{ if eq $read "unread" }}{{ i "inbox" "w-12 h-12" }}{{ else }}{{ i "bell-off" "w-12 h-12" }}{{ end }} 57 + </div> 58 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ if eq $read "unread" }}All caught up!{{ else }}No notifications{{ end }}</p> 11 59 </div> 12 60 {{ end }} 13 - <div class="flex items-center justify-end px-4 py-2 border-t border-gray-200 dark:border-gray-700 text-sm"> 14 - <a href="/notifications" 15 - class="flex items-center gap-1 hover:text-gray-600 dark:hover:text-gray-300 no-underline hover:no-underline"> 16 - view all {{ i "arrow-right" "size-4" }} 17 - </a> 18 - </div> 61 + 62 + <a href="/notifications?read={{ .ReadFilter }}&category={{ .CategoryFilter }}" 63 + class="flex items-center justify-end gap-1 px-4 py-2 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 no-underline hover:no-underline transition-colors duration-150"> 64 + View all {{ i "arrow-right" "size-4" }} 65 + </a> 19 66 {{ end }}
+94 -49
appview/pages/templates/notifications/list.html
··· 1 1 {{ define "title" }}Notifications &middot; Tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex items-center justify-between"> 6 - <p class="text-xl font-bold dark:text-white">Notifications</p> 7 - <a href="/settings/notifications" class="flex items-center gap-2"> 8 - {{ i "settings" "w-4 h-4" }} 9 - Preferences 10 - </a> 4 + <div class="flex items-center justify-between p-4 md:px-0"> 5 + <p class="text-xl font-bold dark:text-white">Notifications</p> 6 + </div> 7 + 8 + {{ $readVals := list 9 + (dict "Key" "inbox" "Value" "Inbox" "Icon" "inbox") 10 + (dict "Key" "unread" "Value" "Unread" "Icon" "glasses") 11 + }} 12 + 13 + {{/* Mobile controls: single row */}} 14 + <div class="md:hidden px-4 pb-4 flex items-center justify-between"> 15 + <div class="flex items-center gap-2"> 16 + {{ template "fragments/tabSelector" (dict "Name" "read" "Values" $readVals "Active" .ReadFilter) }} 17 + <button 18 + hx-post="/notifications/read-all" 19 + hx-swap="none" 20 + hx-on::after-request="window.location.reload()" 21 + class="btn-flat flex items-center gap-2 text-sm"> 22 + {{ i "check-check" "size-4" }} Mark all read 23 + </button> 11 24 </div> 25 + <a href="/settings/notifications" class="btn-flat"> 26 + {{ i "cog" "size-4" }} 27 + </a> 12 28 </div> 13 29 14 - {{if .Notifications}} 15 - <div class="rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700" id="notifications-list"> 16 - {{range .Notifications}} 17 - {{template "notifications/fragments/item" .}} 18 - {{end}} 30 + {{/* Desktop controls: single row */}} 31 + <div class="hidden md:flex md:px-0 pb-4 items-center justify-between gap-4"> 32 + <div class="flex items-center gap-2"> 33 + {{ template "fragments/tabSelector" (dict "Name" "read" "Values" $readVals "Active" .ReadFilter) }} 34 + <button 35 + hx-post="/notifications/read-all" 36 + hx-swap="none" 37 + hx-on::after-request="window.location.reload()" 38 + class="btn-flat flex items-center gap-2 text-sm"> 39 + {{ i "check-check" "size-4" }} Mark all read 40 + </button> 19 41 </div> 42 + <a href="/settings/notifications" class="flex items-center gap-2 text-sm"> 43 + {{ i "cog" "w-4 h-4" }} Preferences 44 + </a> 45 + </div> 20 46 21 - {{else}} 22 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 - <div class="text-center py-12"> 24 - <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 - {{ i "bell-off" "w-16 h-16" }} 26 - </div> 27 - <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 - <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 29 - </div> 47 + {{/* Mobile: single filtered list */}} 48 + <div class="md:hidden"> 49 + {{ template "notifications/fragments/notifList" (dict "Groups" .MobileGroups "ReadFilter" .ReadFilter "CategoryFilter" "") }} 50 + </div> 51 + 52 + {{/* Desktop: two columns */}} 53 + <div class="hidden md:grid md:grid-cols-2 md:gap-6"> 54 + <div> 55 + {{ template "notifications/fragments/notifList" (dict "Groups" .WorkGroups "UnreadCount" .WorkUnreadCount "ReadFilter" .ReadFilter "CategoryFilter" "work") }} 30 56 </div> 31 - {{end}} 57 + <div> 58 + {{ template "notifications/fragments/notifList" (dict "Groups" .SocialGroups "UnreadCount" .SocialUnreadCount "ReadFilter" .ReadFilter "CategoryFilter" "social") }} 59 + </div> 60 + </div> 32 61 33 - {{ template "pagination" . }} 62 + {{ if gt .Total .Page.Limit }} 63 + {{ template "fragments/pagination" (dict 64 + "Page" .Page 65 + "TotalCount" .Total 66 + "BasePath" "/notifications" 67 + "QueryParams" (queryParams "read" .ReadFilter "category" .CategoryFilter) 68 + ) }} 69 + {{ end }} 34 70 {{ end }} 35 71 36 - {{ define "pagination" }} 37 - <div class="flex justify-end mt-4 gap-2"> 38 - {{ if gt .Page.Offset 0 }} 39 - {{ $prev := .Page.Previous }} 40 - <a 41 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 - hx-boost="true" 43 - href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 - > 45 - {{ i "chevron-left" "w-4 h-4" }} 46 - Previous 47 - </a> 48 - {{ else }} 49 - <div></div> 72 + {{ define "notifications/fragments/notifList" }} 73 + {{ $readFilter := .ReadFilter }} 74 + {{ $hasAny := or .Groups.Today .Groups.ThisWeek .Groups.Older }} 75 + <div class="rounded-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 76 + {{ if or .CategoryFilter .UnreadCount }} 77 + <div class="flex items-center justify-between px-2 md:px-6 py-2 bg-white dark:bg-gray-800"> 78 + <h2 class="capitalize text-sm font-medium dark:text-white">{{ .CategoryFilter }}</h2> 79 + {{ with .UnreadCount }} 80 + <span class="min-w-[20px] h-6 px-1 bg-indigo-600 dark:bg-indigo-500 text-white text-xs font-bold rounded-full flex items-center justify-center"> 81 + {{ . }} 82 + </span> 83 + {{ end }} 84 + </div> 85 + {{ end }} 86 + {{ if $hasAny }} 87 + {{ $groupStyle := "px-2 md:px-6 py-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 text-sm" }} 88 + {{ if .Groups.Today }} 89 + <div class="{{ $groupStyle }}">Today</div> 90 + {{ range .Groups.Today }}{{ template "notifications/fragments/item" . }}{{ end }} 91 + {{ end }} 92 + {{ if .Groups.ThisWeek }} 93 + <div class="{{ $groupStyle }}">This week</div> 94 + {{ range .Groups.ThisWeek }}{{ template "notifications/fragments/item" . }}{{ end }} 50 95 {{ end }} 51 - 52 - {{ $next := .Page.Next }} 53 - {{ if lt $next.Offset .Total }} 54 - {{ $next := .Page.Next }} 55 - <a 56 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 - hx-boost="true" 58 - href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 - > 60 - Next 61 - {{ i "chevron-right" "w-4 h-4" }} 62 - </a> 96 + {{ if .Groups.Older }} 97 + <div class="{{ $groupStyle }}">Older</div> 98 + {{ range .Groups.Older }}{{ template "notifications/fragments/item" . }}{{ end }} 63 99 {{ end }} 100 + {{ else }} 101 + <div class="bg-white dark:bg-gray-800 p-6 dark:text-white flex flex-col items-center"> 102 + <div class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600"> 103 + {{ if eq $readFilter "unread" }}{{ i "inbox" "w-12 h-12" }}{{ else }}{{ i "bell-off" "w-12 h-12" }}{{ end }} 104 + </div> 105 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ if eq $readFilter "unread" }}All caught up!{{ else }}No notifications{{ end }}</p> 106 + </div> 107 + {{ end }} 64 108 </div> 65 109 {{ end }} 110 +