Improve templates by using more partialsUnverified
8467321 parent: fa729b0 modified
internal/server/feeds_handler.go +14 -11 | @@ -114,12 +114,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 114 | 114 | |
| 115 | 115 | if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil { |
| 116 | 116 | if errors.Is(err, db.ErrDuplicateSubscription) { |
| 117 | - subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 118 | - s.render(w, r, "feed_list.html", map[string]any{ | |
| 119 | - "User": user, | |
| 120 | - "Subscriptions": subs, | |
| 121 | - "Error": "Already subscribed to this feed.", | |
| 122 | - }) | |
| 117 | + http.Error(w, "Already subscribed to this feed.", http.StatusConflict) | |
| 123 | 118 | return |
| 124 | 119 | } |
| 125 | 120 | s.logger.Error("failed to create subscription", "error", err) |
| @@ -127,10 +122,18 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 127 | 122 | return |
| 128 | 123 | } |
| 129 | 124 | |
| 130 | - subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 131 | - s.render(w, r, "feed_list.html", map[string]any{ | |
| 132 | - "User": user, | |
| 133 | - "Subscriptions": subs, | |
| 125 | + sub, _ := s.db.GetSubscription(r.Context(), user.DID, feedURL) | |
| 126 | + if sub == nil { | |
| 127 | + w.WriteHeader(http.StatusNoContent) | |
| 128 | + return | |
| 129 | + } | |
| 130 | + s.render(w, r, "feed-item.html", map[string]any{ | |
| 131 | + "User": user, | |
| 132 | + "FeedURL": sub.FeedURL, | |
| 133 | + "FeedTitle": sub.FeedTitle, | |
| 134 | + "Category": sub.Category, | |
| 135 | + "FaviconURL": sub.FaviconURL, | |
| 136 | + "UnreadCount": sub.UnreadCount, | |
| 134 | 137 | }) |
| 135 | 138 | } |
| 136 | 139 | |
| @@ -313,7 +316,7 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 313 | 316 | } |
| 314 | 317 | |
| 315 | 318 | subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) |
| 316 | - s.render(w, r, "feeds.html", map[string]any{ | |
| 319 | + s.render(w, r, "feed-list.html", map[string]any{ | |
| 317 | 320 | "User": user, |
| 318 | 321 | "Subscriptions": subs, |
| 319 | 322 | }) |
| @@ -114,12 +114,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 114 | 114 | ||
| 115 | if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil { | 115 | if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil { |
| 116 | if errors.Is(err, db.ErrDuplicateSubscription) { | 116 | if errors.Is(err, db.ErrDuplicateSubscription) { |
| 117 | - subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | 117 | + http.Error(w, "Already subscribed to this feed.", http.StatusConflict) |
| 118 | - s.render(w, r, "feed_list.html", map[string]any{ | ||
| 119 | - "User": user, | ||
| 120 | - "Subscriptions": subs, | ||
| 121 | - "Error": "Already subscribed to this feed.", | ||
| 122 | - }) | ||
| 123 | return | 118 | return |
| 124 | } | 119 | } |
| 125 | s.logger.Error("failed to create subscription", "error", err) | 120 | s.logger.Error("failed to create subscription", "error", err) |
| @@ -127,10 +122,18 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 127 | return | 122 | return |
| 128 | } | 123 | } |
| 129 | 124 | ||
| 130 | - subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | 125 | + sub, _ := s.db.GetSubscription(r.Context(), user.DID, feedURL) |
| 131 | - s.render(w, r, "feed_list.html", map[string]any{ | 126 | + if sub == nil { |
| 132 | - "User": user, | 127 | + w.WriteHeader(http.StatusNoContent) |
| 133 | - "Subscriptions": subs, | 128 | + return |
| 129 | + } | ||
| 130 | + s.render(w, r, "feed-item.html", map[string]any{ | ||
| 131 | + "User": user, | ||
| 132 | + "FeedURL": sub.FeedURL, | ||
| 133 | + "FeedTitle": sub.FeedTitle, | ||
| 134 | + "Category": sub.Category, | ||
| 135 | + "FaviconURL": sub.FaviconURL, | ||
| 136 | + "UnreadCount": sub.UnreadCount, | ||
| 134 | }) | 137 | }) |
| 135 | } | 138 | } |
| 136 | 139 | ||
| @@ -313,7 +316,7 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | |||
| 313 | } | 316 | } |
| 314 | 317 | ||
| 315 | subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | 318 | subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) |
| 316 | - s.render(w, r, "feeds.html", map[string]any{ | 319 | + s.render(w, r, "feed-list.html", map[string]any{ |
| 317 | "User": user, | 320 | "User": user, |
| 318 | "Subscriptions": subs, | 321 | "Subscriptions": subs, |
| 319 | }) | 322 | }) |
modified
internal/tmpl/feeds.html +1 -21 | @@ -61,27 +61,7 @@ | ||
| 61 | 61 | |
| 62 | 62 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider"> |
| 63 | 63 | {{range .Subscriptions}} |
| 64 | - <div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> | |
| 65 | - <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> | |
| 66 | - {{if .FaviconURL.Valid}}<img src="{{.FaviconURL.String}}" class="w-5 h-5 rounded shrink-0" loading="lazy">{{else}}<span class="shrink-0 w-5 h-5 flex items-center justify-center text-spot-muted"><svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">{{template "icon-globe"}}</svg></span>{{end}} | |
| 67 | - <div class="min-w-0 flex-1"> | |
| 68 | - <div class="flex items-center gap-2"> | |
| 69 | - <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | |
| 70 | - {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} | |
| 71 | - </div> | |
| 72 | - <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> | |
| 73 | - </div> | |
| 74 | - </a> | |
| 75 | - <div class="flex items-center gap-3"> | |
| 76 | - {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} | |
| 77 | - <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" | |
| 78 | - hx-confirm="Unsubscribe from this feed?" class="inline"> | |
| 79 | - {{csrfInput $.CSRFToken}} | |
| 80 | - <input type="hidden" name="url" value="{{.FeedURL}}"> | |
| 81 | - <button type="submit" class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | |
| 82 | - </form> | |
| 83 | - </div> | |
| 84 | - </div> | |
| 64 | + {{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | |
| 85 | 65 | {{else}} |
| 86 | 66 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> |
| 87 | 67 | <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> |
| @@ -61,27 +61,7 @@ | |||
| 61 | 61 | ||
| 62 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider"> | 62 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider"> |
| 63 | {{range .Subscriptions}} | 63 | {{range .Subscriptions}} |
| 64 | - <div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> | 64 | + {{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} |
| 65 | - <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> | ||
| 66 | - {{if .FaviconURL.Valid}}<img src="{{.FaviconURL.String}}" class="w-5 h-5 rounded shrink-0" loading="lazy">{{else}}<span class="shrink-0 w-5 h-5 flex items-center justify-center text-spot-muted"><svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">{{template "icon-globe"}}</svg></span>{{end}} | ||
| 67 | - <div class="min-w-0 flex-1"> | ||
| 68 | - <div class="flex items-center gap-2"> | ||
| 69 | - <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | ||
| 70 | - {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} | ||
| 71 | - </div> | ||
| 72 | - <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> | ||
| 73 | - </div> | ||
| 74 | - </a> | ||
| 75 | - <div class="flex items-center gap-3"> | ||
| 76 | - {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} | ||
| 77 | - <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" | ||
| 78 | - hx-confirm="Unsubscribe from this feed?" class="inline"> | ||
| 79 | - {{csrfInput $.CSRFToken}} | ||
| 80 | - <input type="hidden" name="url" value="{{.FeedURL}}"> | ||
| 81 | - <button type="submit" class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | ||
| 82 | - </form> | ||
| 83 | - </div> | ||
| 84 | - </div> | ||
| 85 | {{else}} | 65 | {{else}} |
| 86 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> | 66 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> |
| 87 | <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | 67 | <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> |
added
internal/tmpl/partials/feed-item.html +23 -0 | new file mode 100644 | ||
| @@ -0,0 +1,23 @@ | ||
| 1 | +{{define "feed-item.html"}} | |
| 2 | +<div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> | |
| 3 | + <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> | |
| 4 | + {{if .FaviconURL.Valid}}<img src="{{.FaviconURL.String}}" class="w-5 h-5 rounded shrink-0" loading="lazy">{{else}}<span class="shrink-0 w-5 h-5 flex items-center justify-center text-spot-muted"><svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">{{template "icon-globe"}}</svg></span>{{end}} | |
| 5 | + <div class="min-w-0 flex-1"> | |
| 6 | + <div class="flex items-center gap-2"> | |
| 7 | + <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | |
| 8 | + {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} | |
| 9 | + </div> | |
| 10 | + <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> | |
| 11 | + </div> | |
| 12 | + </a> | |
| 13 | + <div class="flex items-center gap-3"> | |
| 14 | + {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} | |
| 15 | + <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" | |
| 16 | + hx-confirm="Unsubscribe from this feed?" class="inline"> | |
| 17 | + {{csrfInput .CSRFToken}} | |
| 18 | + <input type="hidden" name="url" value="{{.FeedURL}}"> | |
| 19 | + <button type="submit" class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | |
| 20 | + </form> | |
| 21 | + </div> | |
| 22 | +</div> | |
| 23 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,23 @@ | |||
| 1 | +{{define "feed-item.html"}} | ||
| 2 | +<div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> | ||
| 3 | + <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> | ||
| 4 | + {{if .FaviconURL.Valid}}<img src="{{.FaviconURL.String}}" class="w-5 h-5 rounded shrink-0" loading="lazy">{{else}}<span class="shrink-0 w-5 h-5 flex items-center justify-center text-spot-muted"><svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">{{template "icon-globe"}}</svg></span>{{end}} | ||
| 5 | + <div class="min-w-0 flex-1"> | ||
| 6 | + <div class="flex items-center gap-2"> | ||
| 7 | + <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | ||
| 8 | + {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} | ||
| 9 | + </div> | ||
| 10 | + <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> | ||
| 11 | + </div> | ||
| 12 | + </a> | ||
| 13 | + <div class="flex items-center gap-3"> | ||
| 14 | + {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} | ||
| 15 | + <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" | ||
| 16 | + hx-confirm="Unsubscribe from this feed?" class="inline"> | ||
| 17 | + {{csrfInput .CSRFToken}} | ||
| 18 | + <input type="hidden" name="url" value="{{.FeedURL}}"> | ||
| 19 | + <button type="submit" class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | ||
| 20 | + </form> | ||
| 21 | + </div> | ||
| 22 | +</div> | ||
| 23 | +{{end}} | ||
added
internal/tmpl/partials/feed-list.html +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +{{define "feed-list.html"}} | |
| 2 | +{{range .Subscriptions}} | |
| 3 | +{{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | |
| 4 | +{{else}} | |
| 5 | +<div class="flex flex-col items-center justify-center py-12 px-6 text-center"> | |
| 6 | + <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | |
| 7 | + <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | |
| 8 | + </div> | |
| 9 | + <p class="text-sm text-spot-secondary mb-1">No feeds yet</p> | |
| 10 | + <p class="text-xs text-spot-muted">Add one from the sidebar to get started.</p> | |
| 11 | +</div> | |
| 12 | +{{end}} | |
| 13 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +{{define "feed-list.html"}} | ||
| 2 | +{{range .Subscriptions}} | ||
| 3 | +{{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | ||
| 4 | +{{else}} | ||
| 5 | +<div class="flex flex-col items-center justify-center py-12 px-6 text-center"> | ||
| 6 | + <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | ||
| 7 | + <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | ||
| 8 | + </div> | ||
| 9 | + <p class="text-sm text-spot-secondary mb-1">No feeds yet</p> | ||
| 10 | + <p class="text-xs text-spot-muted">Add one from the sidebar to get started.</p> | ||
| 11 | +</div> | ||
| 12 | +{{end}} | ||
| 13 | +{{end}} | ||
modified
static/input.css +5 -0 | @@ -94,3 +94,8 @@ body { | ||
| 94 | 94 | ::-webkit-scrollbar-track { background: var(--spot-bg); } |
| 95 | 95 | ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; } |
| 96 | 96 | ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); } |
| 97 | + | |
| 98 | +.htmx-swapping { | |
| 99 | + opacity: 0; | |
| 100 | + transition: opacity 0.3s ease-out; | |
| 101 | +} | |
| @@ -94,3 +94,8 @@ body { | |||
| 94 | ::-webkit-scrollbar-track { background: var(--spot-bg); } | 94 | ::-webkit-scrollbar-track { background: var(--spot-bg); } |
| 95 | ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; } | 95 | ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; } |
| 96 | ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); } | 96 | ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); } |
| 97 | + | ||
| 98 | +.htmx-swapping { | ||
| 99 | + opacity: 0; | ||
| 100 | + transition: opacity 0.3s ease-out; | ||
| 101 | +} | ||