tenseleyflow/shithub / 3e9cf7e

Browse files

Add dashboard and explore feeds

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3e9cf7ed06bb49547c06625234d86157af98e337
Parents
681f412
Tree
14138a1

10 changed files

StatusFile+-
M internal/social/feed.go 69 0
M internal/social/social_test.go 27 0
A internal/web/handlers/explore.go 112 0
M internal/web/handlers/handlers.go 5 1
M internal/web/handlers/hello.go 46 1
M internal/web/server.go 1 0
M internal/web/static/css/shithub.css 329 0
A internal/web/templates/_feed_row.html 27 0
A internal/web/templates/dashboard.html 80 0
A internal/web/templates/explore/index.html 90 0
internal/social/feed.gomodified
@@ -5,11 +5,13 @@ package social
55
 import (
66
 	"context"
77
 	"encoding/json"
8
+	"errors"
89
 	"fmt"
910
 	"strconv"
1011
 	"strings"
1112
 	"time"
1213
 
14
+	"github.com/jackc/pgx/v5"
1315
 	"github.com/jackc/pgx/v5/pgtype"
1416
 
1517
 	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
@@ -20,6 +22,14 @@ const (
2022
 	defaultTrendingLimit int32 = 10
2123
 )
2224
 
25
+type TrendingScope string
26
+
27
+const (
28
+	TrendingScopeDay   TrendingScope = "day"
29
+	TrendingScopeWeek  TrendingScope = "week"
30
+	TrendingScopeMonth TrendingScope = "month"
31
+)
32
+
2333
 // FeedCursor is S42's keyset cursor over (created_at, event_id).
2434
 type FeedCursor struct {
2535
 	BeforeCreatedAt time.Time
@@ -180,6 +190,30 @@ func TrendingRepos(ctx context.Context, deps Deps, windowDays, limit int32) ([]T
180190
 	return out, nil
181191
 }
182192
 
193
+func CachedTrendingRepos(ctx context.Context, deps Deps, scope TrendingScope, windowDays, limit int32) ([]TrendingRepo, error) {
194
+	if limit <= 0 || limit > 50 {
195
+		limit = defaultTrendingLimit
196
+	}
197
+	row, err := socialdb.New().LatestTrendingSnapshot(ctx, deps.Pool, socialdb.LatestTrendingSnapshotParams{
198
+		Scope: dbTrendingScope(scope),
199
+		Kind:  socialdb.TrendingKindRepos,
200
+	})
201
+	if errors.Is(err, pgx.ErrNoRows) {
202
+		return TrendingRepos(ctx, deps, windowDays, limit)
203
+	}
204
+	if err != nil {
205
+		return nil, fmt.Errorf("latest trending repos snapshot: %w", err)
206
+	}
207
+	var repos []TrendingRepo
208
+	if err := json.Unmarshal(row.Payload, &repos); err != nil {
209
+		return nil, fmt.Errorf("decode trending repos snapshot: %w", err)
210
+	}
211
+	if int32(len(repos)) > limit {
212
+		repos = repos[:limit]
213
+	}
214
+	return repos, nil
215
+}
216
+
183217
 func TrendingUsers(ctx context.Context, deps Deps, windowDays, limit int32) ([]TrendingUser, error) {
184218
 	if windowDays <= 0 {
185219
 		windowDays = 7
@@ -204,6 +238,30 @@ func TrendingUsers(ctx context.Context, deps Deps, windowDays, limit int32) ([]T
204238
 	return out, nil
205239
 }
206240
 
241
+func CachedTrendingUsers(ctx context.Context, deps Deps, scope TrendingScope, windowDays, limit int32) ([]TrendingUser, error) {
242
+	if limit <= 0 || limit > 50 {
243
+		limit = defaultTrendingLimit
244
+	}
245
+	row, err := socialdb.New().LatestTrendingSnapshot(ctx, deps.Pool, socialdb.LatestTrendingSnapshotParams{
246
+		Scope: dbTrendingScope(scope),
247
+		Kind:  socialdb.TrendingKindUsers,
248
+	})
249
+	if errors.Is(err, pgx.ErrNoRows) {
250
+		return TrendingUsers(ctx, deps, windowDays, limit)
251
+	}
252
+	if err != nil {
253
+		return nil, fmt.Errorf("latest trending users snapshot: %w", err)
254
+	}
255
+	var users []TrendingUser
256
+	if err := json.Unmarshal(row.Payload, &users); err != nil {
257
+		return nil, fmt.Errorf("decode trending users snapshot: %w", err)
258
+	}
259
+	if int32(len(users)) > limit {
260
+		users = users[:limit]
261
+	}
262
+	return users, nil
263
+}
264
+
207265
 // CaptureTrendingSnapshots computes the S42 denormalized rankings for
208266
 // day/week/month windows. It is idempotent in behavior: inserting a new
209267
 // snapshot never mutates prior rows, so stale readers still have a valid
@@ -397,3 +455,14 @@ func timeFromPG(t pgtype.Timestamptz) time.Time {
397455
 	}
398456
 	return t.Time
399457
 }
458
+
459
+func dbTrendingScope(scope TrendingScope) socialdb.TrendingScope {
460
+	switch scope {
461
+	case TrendingScopeDay:
462
+		return socialdb.TrendingScopeDay
463
+	case TrendingScopeMonth:
464
+		return socialdb.TrendingScopeMonth
465
+	default:
466
+		return socialdb.TrendingScopeWeek
467
+	}
468
+}
internal/social/social_test.gomodified
@@ -397,3 +397,30 @@ func TestStargazerList_ExcludesSuspended(t *testing.T) {
397397
 		t.Errorf("expected only good user, got %d rows = %+v", len(rows), rows)
398398
 	}
399399
 }
400
+
401
+func TestCaptureTrendingSnapshots_FeedsCachedTrending(t *testing.T) {
402
+	pool, deps, _, repoID := setup(t)
403
+	actorID := mustCreateUser(t, pool, "bob")
404
+	ctx := context.Background()
405
+
406
+	if err := social.Star(ctx, deps, actorID, repoID, true); err != nil {
407
+		t.Fatalf("Star: %v", err)
408
+	}
409
+	if err := social.CaptureTrendingSnapshots(ctx, deps); err != nil {
410
+		t.Fatalf("CaptureTrendingSnapshots: %v", err)
411
+	}
412
+	repos, err := social.CachedTrendingRepos(ctx, deps, social.TrendingScopeWeek, 7, 5)
413
+	if err != nil {
414
+		t.Fatalf("CachedTrendingRepos: %v", err)
415
+	}
416
+	if len(repos) == 0 || repos[0].RepoID != repoID {
417
+		t.Fatalf("cached trending repos = %+v, want repo %d first", repos, repoID)
418
+	}
419
+	users, err := social.CachedTrendingUsers(ctx, deps, social.TrendingScopeWeek, 7, 5)
420
+	if err != nil {
421
+		t.Fatalf("CachedTrendingUsers: %v", err)
422
+	}
423
+	if len(users) == 0 || users[0].UserID != actorID {
424
+		t.Fatalf("cached trending users = %+v, want actor %d first", users, actorID)
425
+	}
426
+}
internal/web/handlers/explore.goadded
@@ -0,0 +1,112 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"log/slog"
7
+	"net/http"
8
+	"strconv"
9
+	"strings"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/social"
15
+	"github.com/tenseleyFlow/shithub/internal/web/render"
16
+)
17
+
18
+const feedDisplayLimit int32 = 20
19
+
20
+type exploreHandler struct {
21
+	render *render.Renderer
22
+	logger *slog.Logger
23
+	pool   *pgxpool.Pool
24
+}
25
+
26
+func (h exploreHandler) ServeExplore(w http.ResponseWriter, r *http.Request) {
27
+	h.serve(w, r, "Explore", "/explore", "activity")
28
+}
29
+
30
+func (h exploreHandler) ServeTrending(w http.ResponseWriter, r *http.Request) {
31
+	h.serve(w, r, "Trending", "/trending", "trending")
32
+}
33
+
34
+func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, path, activeTab string) {
35
+	var (
36
+		feed          []social.FeedItem
37
+		hasNext       bool
38
+		nextURL       string
39
+		trendingRepos []social.TrendingRepo
40
+		trendingUsers []social.TrendingUser
41
+	)
42
+	if h.pool != nil {
43
+		deps := social.Deps{Pool: h.pool, Logger: h.logger}
44
+		feed, hasNext, nextURL = feedPageFor(r, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
45
+			return social.PublicFeed(r.Context(), deps, cursor, limit)
46
+		})
47
+		var err error
48
+		trendingRepos, err = social.CachedTrendingRepos(r.Context(), deps, social.TrendingScopeWeek, 7, 10)
49
+		if err != nil && h.logger != nil {
50
+			h.logger.WarnContext(r.Context(), "explore trending repos", "error", err)
51
+		}
52
+		trendingUsers, err = social.CachedTrendingUsers(r.Context(), deps, social.TrendingScopeWeek, 7, 8)
53
+		if err != nil && h.logger != nil {
54
+			h.logger.WarnContext(r.Context(), "explore trending users", "error", err)
55
+		}
56
+	}
57
+
58
+	data := map[string]any{
59
+		"Title":         title,
60
+		"ActiveTab":     activeTab,
61
+		"Feed":          feed,
62
+		"FeedHasNext":   hasNext,
63
+		"FeedNextURL":   nextURL,
64
+		"TrendingRepos": trendingRepos,
65
+		"TrendingUsers": trendingUsers,
66
+		"Path":          path,
67
+	}
68
+	if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
69
+		if h.logger != nil {
70
+			h.logger.Error("render explore", "error", err)
71
+		}
72
+		http.Error(w, "internal server error", http.StatusInternalServerError)
73
+	}
74
+}
75
+
76
+func feedPageFor(r *http.Request, load func(social.FeedCursor, int32) ([]social.FeedItem, error)) ([]social.FeedItem, bool, string) {
77
+	items, err := load(parseFeedCursor(r), feedDisplayLimit+1)
78
+	if err != nil {
79
+		return nil, false, ""
80
+	}
81
+	if int32(len(items)) <= feedDisplayLimit {
82
+		return items, false, ""
83
+	}
84
+	display := items[:feedDisplayLimit]
85
+	return display, true, feedNextURL(r, display[len(display)-1])
86
+}
87
+
88
+func parseFeedCursor(r *http.Request) social.FeedCursor {
89
+	raw := r.URL.Query().Get("before")
90
+	if raw == "" {
91
+		return social.FeedCursor{}
92
+	}
93
+	parts := strings.SplitN(raw, "~", 2)
94
+	if len(parts) != 2 {
95
+		return social.FeedCursor{}
96
+	}
97
+	createdAt, err := time.Parse(time.RFC3339Nano, parts[0])
98
+	if err != nil {
99
+		return social.FeedCursor{}
100
+	}
101
+	id, err := strconv.ParseInt(parts[1], 10, 64)
102
+	if err != nil || id <= 0 {
103
+		return social.FeedCursor{}
104
+	}
105
+	return social.FeedCursor{BeforeCreatedAt: createdAt, BeforeID: id}
106
+}
107
+
108
+func feedNextURL(r *http.Request, item social.FeedItem) string {
109
+	q := r.URL.Query()
110
+	q.Set("before", item.CreatedAt.UTC().Format(time.RFC3339Nano)+"~"+strconv.FormatInt(item.ID, 10))
111
+	return r.URL.Path + "?" + q.Encode()
112
+}
internal/web/handlers/handlers.gomodified
@@ -15,6 +15,7 @@ import (
1515
 	"time"
1616
 
1717
 	"github.com/go-chi/chi/v5"
18
+	"github.com/jackc/pgx/v5/pgxpool"
1819
 
1920
 	"github.com/tenseleyFlow/shithub/internal/auth/session"
2021
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
@@ -30,6 +31,7 @@ type Deps struct {
3031
 	StaticFS     fs.FS
3132
 	LogoSVG      string
3233
 	SessionStore session.Store
34
+	Pool         *pgxpool.Pool
3335
 	// CookieSecure is the Secure flag for session-related cookies
3436
 	// (currently the CSRF cookie). Mirrors session.Config.Secure
3537
 	// from the loaded config so the CSRF cookie matches the
@@ -253,7 +255,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
253255
 		r.Use(middleware.Compress)
254256
 		r.Use(middleware.Timeout(30 * time.Second))
255257
 		r.Use(csrf)
256
-		r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger}.ServeHTTP)
258
+		r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger, pool: deps.Pool}.ServeHTTP)
259
+		r.Get("/explore", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeExplore)
260
+		r.Get("/trending", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeTrending)
257261
 		// /internal/panic is a dev affordance: GET it to trigger the
258262
 		// panic-recovery path so an operator can confirm the styled 500
259263
 		// page renders. S35 will gate this behind a dev flag.
internal/web/handlers/hello.gomodified
@@ -7,6 +7,9 @@ import (
77
 	"log/slog"
88
 	"net/http"
99
 
10
+	"github.com/jackc/pgx/v5/pgxpool"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/social"
1013
 	"github.com/tenseleyFlow/shithub/internal/version"
1114
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1215
 	"github.com/tenseleyFlow/shithub/internal/web/render"
@@ -16,6 +19,7 @@ type helloHandler struct {
1619
 	render  *render.Renderer
1720
 	logoSVG string
1821
 	logger  *slog.Logger
22
+	pool    *pgxpool.Pool
1923
 }
2024
 
2125
 type helloData struct {
@@ -49,13 +53,19 @@ type helloData struct {
4953
 }
5054
 
5155
 func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
56
+	viewer := middleware.CurrentUserFromContext(r.Context())
57
+	if viewer.ID != 0 && h.pool != nil {
58
+		h.serveDashboard(w, r, viewer)
59
+		return
60
+	}
61
+
5262
 	data := helloData{
5363
 		Title:     "Welcome",
5464
 		Version:   version.Version,
5565
 		Commit:    version.Commit,
5666
 		BuiltAt:   version.BuiltAt,
5767
 		LogoSVG:   template.HTML(h.logoSVG), // #nosec G203 — embedded server-owned asset
58
-		Viewer:    middleware.CurrentUserFromContext(r.Context()),
68
+		Viewer:    viewer,
5969
 		CSRFToken: middleware.CSRFTokenForRequest(r),
6070
 	}
6171
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -64,3 +74,38 @@ func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6474
 		http.Error(w, "internal server error", http.StatusInternalServerError)
6575
 	}
6676
 }
77
+
78
+func (h helloHandler) serveDashboard(w http.ResponseWriter, r *http.Request, viewer middleware.CurrentUser) {
79
+	deps := social.Deps{Pool: h.pool, Logger: h.logger}
80
+	feed, hasNext, nextURL := feedPageFor(r, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
81
+		return social.DashboardFeed(r.Context(), deps, viewer.ID, cursor, limit)
82
+	})
83
+	repos, err := social.DashboardRepos(r.Context(), deps, viewer.ID, 8)
84
+	if err != nil && h.logger != nil {
85
+		h.logger.WarnContext(r.Context(), "dashboard repos", "error", err)
86
+	}
87
+	trendingRepos, err := social.CachedTrendingRepos(r.Context(), deps, social.TrendingScopeWeek, 7, 5)
88
+	if err != nil && h.logger != nil {
89
+		h.logger.WarnContext(r.Context(), "dashboard trending repos", "error", err)
90
+	}
91
+	trendingUsers, err := social.CachedTrendingUsers(r.Context(), deps, social.TrendingScopeWeek, 7, 5)
92
+	if err != nil && h.logger != nil {
93
+		h.logger.WarnContext(r.Context(), "dashboard trending users", "error", err)
94
+	}
95
+
96
+	data := map[string]any{
97
+		"Title":         "Home",
98
+		"TopRepos":      repos,
99
+		"Feed":          feed,
100
+		"FeedHasNext":   hasNext,
101
+		"FeedNextURL":   nextURL,
102
+		"TrendingRepos": trendingRepos,
103
+		"TrendingUsers": trendingUsers,
104
+	}
105
+	if err := h.render.RenderPage(w, r, "dashboard", data); err != nil {
106
+		if h.logger != nil {
107
+			h.logger.Error("render dashboard", "error", err)
108
+		}
109
+		http.Error(w, "internal server error", http.StatusInternalServerError)
110
+	}
111
+}
internal/web/server.gomodified
@@ -145,6 +145,7 @@ func Run(ctx context.Context, opts Options) error {
145145
 		StaticFS:     StaticFS(),
146146
 		LogoSVG:      string(logoBytes),
147147
 		SessionStore: sessionStore,
148
+		Pool:         pool,
148149
 		CookieSecure: cfg.Session.Secure,
149150
 	}
150151
 	if pool != nil {
internal/web/static/css/shithub.cssmodified
@@ -8479,3 +8479,332 @@ button.shithub-repo-action {
84798479
 }
84808480
 .shithub-imp-write { background: #ffd33d; color: #1a1f24; padding: 0 0.4em; border-radius: 4px; font-weight: 600; }
84818481
 .shithub-imp-read  { background: rgba(255, 255, 255, 0.2); padding: 0 0.4em; border-radius: 4px; }
8482
+
8483
+/* S42 — social dashboard and Explore feed. */
8484
+.shithub-dashboard-page,
8485
+.shithub-explore-page {
8486
+  width: 100%;
8487
+  padding: 1.5rem 1rem 2.5rem;
8488
+}
8489
+.shithub-dashboard-shell {
8490
+  display: grid;
8491
+  grid-template-columns: minmax(220px, 296px) minmax(0, 1fr) minmax(220px, 296px);
8492
+  gap: 2rem;
8493
+  max-width: 1280px;
8494
+  margin: 0 auto;
8495
+}
8496
+.shithub-explore-shell {
8497
+  display: grid;
8498
+  grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
8499
+  gap: 2rem;
8500
+  max-width: 1100px;
8501
+  margin: 0 auto;
8502
+}
8503
+.shithub-dashboard-left,
8504
+.shithub-dashboard-right,
8505
+.shithub-explore-right {
8506
+  min-width: 0;
8507
+}
8508
+.shithub-dashboard-left {
8509
+  position: sticky;
8510
+  top: 1rem;
8511
+  align-self: start;
8512
+}
8513
+.shithub-dashboard-sidehead,
8514
+.shithub-feed-toolbar,
8515
+.shithub-explore-head {
8516
+  display: flex;
8517
+  align-items: center;
8518
+  justify-content: space-between;
8519
+  gap: 0.75rem;
8520
+}
8521
+.shithub-dashboard-sidehead h2,
8522
+.shithub-feed-toolbar h2,
8523
+.shithub-side-panel h2,
8524
+.shithub-trending-section h2 {
8525
+  margin: 0;
8526
+  font-size: 1rem;
8527
+}
8528
+.shithub-dashboard-main h1,
8529
+.shithub-explore-head h1 {
8530
+  margin: 0 0 1.25rem;
8531
+  font-size: 1.6rem;
8532
+  line-height: 1.25;
8533
+}
8534
+.shithub-dashboard-filter {
8535
+  width: 100%;
8536
+  margin: 0.75rem 0 0.65rem;
8537
+  padding: 0.45rem 0.7rem;
8538
+  border: 1px solid var(--border-default);
8539
+  border-radius: 6px;
8540
+  background: var(--canvas-default);
8541
+  color: var(--fg-default);
8542
+}
8543
+.shithub-dashboard-repo-list,
8544
+.shithub-feed-list,
8545
+.shithub-trending-mini-list,
8546
+.shithub-trending-user-list,
8547
+.shithub-trending-repo-list {
8548
+  padding: 0;
8549
+  margin: 0;
8550
+  list-style: none;
8551
+}
8552
+.shithub-dashboard-repo-list {
8553
+  display: grid;
8554
+  gap: 0.45rem;
8555
+}
8556
+.shithub-dashboard-repo-list a,
8557
+.shithub-trending-user-list a {
8558
+  display: inline-flex;
8559
+  align-items: center;
8560
+  gap: 0.45rem;
8561
+  min-width: 0;
8562
+  color: var(--fg-default);
8563
+  font-weight: 600;
8564
+}
8565
+.shithub-dashboard-repo-list img,
8566
+.shithub-trending-user-list img,
8567
+.shithub-feed-avatar img {
8568
+  border-radius: 50%;
8569
+}
8570
+.shithub-dashboard-empty,
8571
+.shithub-feed-empty-inline {
8572
+  margin: 0.75rem 0 0;
8573
+  color: var(--fg-muted);
8574
+  font-size: 0.875rem;
8575
+}
8576
+.shithub-feed-toolbar {
8577
+  margin-bottom: 0.75rem;
8578
+}
8579
+.shithub-feed-list {
8580
+  border: 1px solid var(--border-default);
8581
+  border-radius: 6px;
8582
+  background: var(--canvas-default);
8583
+}
8584
+.shithub-feed-row {
8585
+  display: grid;
8586
+  grid-template-columns: 40px minmax(0, 1fr);
8587
+  gap: 1rem;
8588
+  padding: 1rem;
8589
+  border-top: 1px solid var(--border-default);
8590
+}
8591
+.shithub-feed-row:first-child {
8592
+  border-top: 0;
8593
+}
8594
+.shithub-feed-avatar {
8595
+  display: inline-flex;
8596
+  width: 40px;
8597
+  height: 40px;
8598
+}
8599
+.shithub-feed-head {
8600
+  margin: 0;
8601
+  color: var(--fg-muted);
8602
+  font-size: 0.95rem;
8603
+}
8604
+.shithub-feed-head a {
8605
+  color: var(--fg-default);
8606
+}
8607
+.shithub-feed-head time {
8608
+  display: inline-block;
8609
+  color: var(--fg-muted);
8610
+  font-size: 0.875rem;
8611
+}
8612
+.shithub-feed-target {
8613
+  font-weight: 600;
8614
+}
8615
+.shithub-feed-repo {
8616
+  margin-top: 0.8rem;
8617
+  padding: 0.85rem;
8618
+  border: 1px solid var(--border-default);
8619
+  border-radius: 6px;
8620
+  background: var(--canvas-subtle);
8621
+}
8622
+.shithub-feed-repo-title {
8623
+  display: inline-flex;
8624
+  align-items: center;
8625
+  gap: 0.4rem;
8626
+  font-weight: 600;
8627
+}
8628
+.shithub-feed-repo p {
8629
+  margin: 0.4rem 0 0;
8630
+  color: var(--fg-muted);
8631
+  font-size: 0.875rem;
8632
+}
8633
+.shithub-feed-meta {
8634
+  display: flex;
8635
+  flex-wrap: wrap;
8636
+  gap: 0.9rem;
8637
+  padding: 0;
8638
+  margin: 0.7rem 0 0;
8639
+  color: var(--fg-muted);
8640
+  list-style: none;
8641
+  font-size: 0.8rem;
8642
+}
8643
+.shithub-feed-meta li {
8644
+  display: inline-flex;
8645
+  align-items: center;
8646
+  gap: 0.3rem;
8647
+}
8648
+.shithub-lang-dot {
8649
+  width: 10px;
8650
+  height: 10px;
8651
+  border-radius: 50%;
8652
+  background: #3572a5;
8653
+}
8654
+.shithub-feed-empty {
8655
+  padding: 2rem;
8656
+  border: 1px solid var(--border-default);
8657
+  border-radius: 6px;
8658
+  background: var(--canvas-default);
8659
+  color: var(--fg-muted);
8660
+}
8661
+.shithub-feed-empty h2 {
8662
+  display: flex;
8663
+  align-items: center;
8664
+  gap: 0.5rem;
8665
+  margin: 0 0 0.5rem;
8666
+  color: var(--fg-default);
8667
+  font-size: 1rem;
8668
+}
8669
+.shithub-feed-empty p {
8670
+  margin: 0 0 1rem;
8671
+}
8672
+.shithub-feed-pagination {
8673
+  display: flex;
8674
+  justify-content: center;
8675
+  padding-top: 1rem;
8676
+}
8677
+.shithub-side-panel {
8678
+  padding: 0 0 1.25rem;
8679
+  margin-bottom: 1.25rem;
8680
+  border-bottom: 1px solid var(--border-default);
8681
+}
8682
+.shithub-side-panel h2 {
8683
+  display: flex;
8684
+  align-items: center;
8685
+  gap: 0.4rem;
8686
+  margin-bottom: 0.75rem;
8687
+}
8688
+.shithub-trending-mini-list,
8689
+.shithub-trending-user-list {
8690
+  display: grid;
8691
+  gap: 0.85rem;
8692
+}
8693
+.shithub-trending-mini-list a {
8694
+  color: var(--fg-default);
8695
+  font-weight: 600;
8696
+}
8697
+.shithub-trending-mini-list p {
8698
+  margin: 0.2rem 0;
8699
+  color: var(--fg-muted);
8700
+  font-size: 0.85rem;
8701
+  line-height: 1.35;
8702
+}
8703
+.shithub-trending-mini-list span,
8704
+.shithub-trending-user-list span {
8705
+  display: inline-flex;
8706
+  align-items: center;
8707
+  gap: 0.25rem;
8708
+  color: var(--fg-muted);
8709
+  font-size: 0.8rem;
8710
+}
8711
+.shithub-explore-tabs {
8712
+  display: inline-flex;
8713
+  align-items: center;
8714
+  gap: 0.35rem;
8715
+}
8716
+.shithub-explore-tabs a {
8717
+  display: inline-flex;
8718
+  align-items: center;
8719
+  gap: 0.35rem;
8720
+  padding: 0.45rem 0.7rem;
8721
+  border: 1px solid transparent;
8722
+  border-radius: 6px;
8723
+  color: var(--fg-muted);
8724
+  font-weight: 600;
8725
+}
8726
+.shithub-explore-tabs a:hover,
8727
+.shithub-explore-tabs a.is-selected {
8728
+  border-color: var(--border-default);
8729
+  background: var(--canvas-subtle);
8730
+  color: var(--fg-default);
8731
+  text-decoration: none;
8732
+}
8733
+.shithub-trending-section {
8734
+  border: 1px solid var(--border-default);
8735
+  border-radius: 6px;
8736
+  background: var(--canvas-default);
8737
+}
8738
+.shithub-trending-section h2 {
8739
+  padding: 1rem;
8740
+  border-bottom: 1px solid var(--border-default);
8741
+}
8742
+.shithub-trending-repo-row {
8743
+  display: flex;
8744
+  justify-content: space-between;
8745
+  gap: 1rem;
8746
+  padding: 1rem;
8747
+  border-top: 1px solid var(--border-default);
8748
+}
8749
+.shithub-trending-repo-row:first-child {
8750
+  border-top: 0;
8751
+}
8752
+.shithub-trending-repo-row h3 {
8753
+  margin: 0;
8754
+  font-size: 1.05rem;
8755
+}
8756
+.shithub-trending-repo-row h3 a {
8757
+  display: inline-flex;
8758
+  align-items: center;
8759
+  gap: 0.4rem;
8760
+}
8761
+.shithub-trending-repo-row p {
8762
+  margin: 0.45rem 0 0;
8763
+  color: var(--fg-muted);
8764
+}
8765
+.shithub-trending-score {
8766
+  flex: 0 0 auto;
8767
+  align-self: flex-start;
8768
+  min-width: 2.5rem;
8769
+  padding: 0.15rem 0.45rem;
8770
+  border: 1px solid var(--border-default);
8771
+  border-radius: 999px;
8772
+  color: var(--fg-muted);
8773
+  text-align: center;
8774
+  font-size: 0.8rem;
8775
+  font-weight: 600;
8776
+}
8777
+@media (max-width: 960px) {
8778
+  .shithub-dashboard-shell,
8779
+  .shithub-explore-shell {
8780
+    grid-template-columns: 1fr;
8781
+  }
8782
+  .shithub-dashboard-left {
8783
+    position: static;
8784
+  }
8785
+  .shithub-dashboard-right,
8786
+  .shithub-explore-right {
8787
+    display: none;
8788
+  }
8789
+}
8790
+@media (max-width: 640px) {
8791
+  .shithub-feed-row {
8792
+    grid-template-columns: 32px minmax(0, 1fr);
8793
+    gap: 0.75rem;
8794
+    padding: 0.85rem;
8795
+  }
8796
+  .shithub-feed-avatar,
8797
+  .shithub-feed-avatar img {
8798
+    width: 32px;
8799
+    height: 32px;
8800
+  }
8801
+  .shithub-explore-head,
8802
+  .shithub-dashboard-sidehead,
8803
+  .shithub-feed-toolbar {
8804
+    align-items: flex-start;
8805
+    flex-direction: column;
8806
+  }
8807
+  .shithub-trending-repo-row {
8808
+    flex-direction: column;
8809
+  }
8810
+}
internal/web/templates/_feed_row.htmladded
@@ -0,0 +1,27 @@
1
+{{ define "feed-row" -}}
2
+<li class="shithub-feed-row">
3
+  <a class="shithub-feed-avatar" href="/{{ .ActorUsername }}" aria-label="{{ .ActorUsername }}">
4
+    <img src="/avatars/{{ .ActorUsername }}" alt="" width="40" height="40">
5
+  </a>
6
+  <div class="shithub-feed-body">
7
+    <p class="shithub-feed-head">
8
+      <a href="/{{ .ActorUsername }}"><strong>{{ if .ActorDisplayName }}{{ .ActorDisplayName }}{{ else }}{{ .ActorUsername }}{{ end }}</strong></a>
9
+      <span>{{ .Verb }}</span>
10
+      {{ if .ItemURL }}<a href="{{ .ItemURL }}" class="shithub-feed-target">{{ .ItemTitle }}</a>{{ else if .ItemTitle }}<span class="shithub-feed-target">{{ .ItemTitle }}</span>{{ end }}
11
+      {{ if and .RepoFullName (ne .ItemTitle .RepoFullName) }}<span>in</span> <a href="{{ .RepoURL }}" class="shithub-feed-target">{{ .RepoFullName }}</a>{{ end }}
12
+      <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt }}</time>
13
+    </p>
14
+    {{ if .Repo }}
15
+    <div class="shithub-feed-repo">
16
+      <a href="{{ .RepoURL }}" class="shithub-feed-repo-title">{{ octicon "repo" }} {{ .RepoFullName }}</a>
17
+      {{ if .Repo.Description }}<p>{{ .Repo.Description }}</p>{{ end }}
18
+      <ul class="shithub-feed-meta">
19
+        {{ if .Repo.PrimaryLanguage }}<li><span class="shithub-lang-dot" aria-hidden="true"></span>{{ .Repo.PrimaryLanguage }}</li>{{ end }}
20
+        <li>{{ octicon "star" }} {{ .Repo.StarCount }}</li>
21
+        <li>{{ octicon "repo-forked" }} {{ .Repo.ForkCount }}</li>
22
+      </ul>
23
+    </div>
24
+    {{ end }}
25
+  </div>
26
+</li>
27
+{{- end }}
internal/web/templates/dashboard.htmladded
@@ -0,0 +1,80 @@
1
+{{ define "page" -}}
2
+<section class="shithub-dashboard-page">
3
+  <div class="shithub-dashboard-shell">
4
+    <aside class="shithub-dashboard-left" aria-label="Your repositories">
5
+      <div class="shithub-dashboard-sidehead">
6
+        <h2>Top repositories</h2>
7
+        <a href="/new" class="shithub-button shithub-button-primary shithub-button-small">{{ octicon "repo" }} New</a>
8
+      </div>
9
+      <label class="sr-only" for="dashboard-repo-filter">Find a repository</label>
10
+      <input id="dashboard-repo-filter" class="shithub-dashboard-filter" type="search" placeholder="Find a repository..." autocomplete="off">
11
+      {{ if .TopRepos }}
12
+      <ol class="shithub-dashboard-repo-list">
13
+        {{ range .TopRepos }}
14
+        <li>
15
+          <a href="/{{ $.Viewer.Username }}/{{ .Name }}"><img src="/avatars/{{ $.Viewer.Username }}" alt="" width="18" height="18"> {{ $.Viewer.Username }}/{{ .Name }}</a>
16
+        </li>
17
+        {{ end }}
18
+      </ol>
19
+      {{ else }}
20
+      <p class="shithub-dashboard-empty">No repositories yet.</p>
21
+      {{ end }}
22
+    </aside>
23
+
24
+    <main class="shithub-dashboard-main">
25
+      <h1>Home</h1>
26
+      <div class="shithub-feed-toolbar">
27
+        <h2>Feed</h2>
28
+        <a class="shithub-button shithub-button-small" href="/explore">{{ octicon "pulse" }} Explore</a>
29
+      </div>
30
+      {{ if .Feed }}
31
+      <ol class="shithub-feed-list">
32
+        {{ range .Feed }}{{ template "feed-row" . }}{{ end }}
33
+      </ol>
34
+      {{ else }}
35
+      <div class="shithub-feed-empty">
36
+        <h2>{{ octicon "people" }} Follow people and organizations to build your feed</h2>
37
+        <p>Stars, forks, pushes, issues, pull requests, and follows from your network will appear here.</p>
38
+        <a href="/explore" class="shithub-button">Explore activity</a>
39
+      </div>
40
+      {{ end }}
41
+      {{ if .FeedHasNext }}
42
+      <nav class="shithub-feed-pagination" aria-label="Feed pagination">
43
+        <a class="shithub-button" href="{{ .FeedNextURL }}">More</a>
44
+      </nav>
45
+      {{ end }}
46
+    </main>
47
+
48
+    <aside class="shithub-dashboard-right" aria-label="Discover">
49
+      <section class="shithub-side-panel">
50
+        <h2>{{ octicon "pulse" }} Trending repositories</h2>
51
+        {{ if .TrendingRepos }}
52
+        <ol class="shithub-trending-mini-list">
53
+          {{ range .TrendingRepos }}
54
+          <li>
55
+            <a href="/{{ .Owner }}/{{ .Name }}">{{ .Owner }}/{{ .Name }}</a>
56
+            {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
57
+            <span>{{ octicon "star" }} {{ .StarCount }}</span>
58
+          </li>
59
+          {{ end }}
60
+        </ol>
61
+        {{ else }}<p class="shithub-dashboard-empty">No public activity yet.</p>{{ end }}
62
+      </section>
63
+
64
+      <section class="shithub-side-panel">
65
+        <h2>{{ octicon "people" }} Trending developers</h2>
66
+        {{ if .TrendingUsers }}
67
+        <ol class="shithub-trending-user-list">
68
+          {{ range .TrendingUsers }}
69
+          <li>
70
+            <a href="/{{ .Username }}"><img src="/avatars/{{ .Username }}" alt="" width="24" height="24"> {{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a>
71
+            <span>@{{ .Username }}</span>
72
+          </li>
73
+          {{ end }}
74
+        </ol>
75
+        {{ else }}<p class="shithub-dashboard-empty">No developers trending yet.</p>{{ end }}
76
+      </section>
77
+    </aside>
78
+  </div>
79
+</section>
80
+{{- end }}
internal/web/templates/explore/index.htmladded
@@ -0,0 +1,90 @@
1
+{{ define "page" -}}
2
+<section class="shithub-explore-page">
3
+  <div class="shithub-explore-shell">
4
+    <main class="shithub-explore-main">
5
+      <div class="shithub-explore-head">
6
+        <h1>{{ if eq .ActiveTab "trending" }}Trending{{ else }}Explore{{ end }}</h1>
7
+        <nav class="shithub-explore-tabs" aria-label="Explore">
8
+          <a href="/explore"{{ if eq .ActiveTab "activity" }} class="is-selected" aria-current="page"{{ end }}>{{ octicon "pulse" }} Activity</a>
9
+          <a href="/trending"{{ if eq .ActiveTab "trending" }} class="is-selected" aria-current="page"{{ end }}>{{ octicon "star" }} Trending</a>
10
+        </nav>
11
+      </div>
12
+
13
+      {{ if eq .ActiveTab "trending" }}
14
+      <section class="shithub-trending-section" aria-labelledby="trending-repositories-title">
15
+        <h2 id="trending-repositories-title">Trending repositories</h2>
16
+        {{ if .TrendingRepos }}
17
+        <ol class="shithub-trending-repo-list">
18
+          {{ range .TrendingRepos }}
19
+          <li class="shithub-trending-repo-row">
20
+            <div>
21
+              <h3><a href="/{{ .Owner }}/{{ .Name }}">{{ octicon "repo" }} {{ .Owner }}/{{ .Name }}</a></h3>
22
+              {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
23
+              <ul class="shithub-feed-meta">
24
+                {{ if .PrimaryLanguage }}<li><span class="shithub-lang-dot" aria-hidden="true"></span>{{ .PrimaryLanguage }}</li>{{ end }}
25
+                <li>{{ octicon "star" }} {{ .StarCount }}</li>
26
+                <li>{{ octicon "repo-forked" }} {{ .ForkCount }}</li>
27
+              </ul>
28
+            </div>
29
+            <span class="shithub-trending-score">{{ .Score }}</span>
30
+          </li>
31
+          {{ end }}
32
+        </ol>
33
+        {{ else }}<p class="shithub-feed-empty-inline">No public repositories are trending yet.</p>{{ end }}
34
+      </section>
35
+      {{ else }}
36
+      <div class="shithub-feed-toolbar">
37
+        <h2>Public activity</h2>
38
+        <a class="shithub-button shithub-button-small" href="/trending">{{ octicon "star" }} Trending</a>
39
+      </div>
40
+      {{ if .Feed }}
41
+      <ol class="shithub-feed-list">
42
+        {{ range .Feed }}{{ template "feed-row" . }}{{ end }}
43
+      </ol>
44
+      {{ else }}
45
+      <div class="shithub-feed-empty">
46
+        <h2>{{ octicon "pulse" }} No public activity yet</h2>
47
+        <p>Public stars, forks, pushes, issues, pull requests, and follows will appear here.</p>
48
+      </div>
49
+      {{ end }}
50
+      {{ if .FeedHasNext }}
51
+      <nav class="shithub-feed-pagination" aria-label="Public activity pagination">
52
+        <a class="shithub-button" href="{{ .FeedNextURL }}">More</a>
53
+      </nav>
54
+      {{ end }}
55
+      {{ end }}
56
+    </main>
57
+
58
+    <aside class="shithub-explore-right" aria-label="Trending">
59
+      <section class="shithub-side-panel">
60
+        <h2>{{ octicon "star" }} Repositories</h2>
61
+        {{ if .TrendingRepos }}
62
+        <ol class="shithub-trending-mini-list">
63
+          {{ range .TrendingRepos }}
64
+          <li>
65
+            <a href="/{{ .Owner }}/{{ .Name }}">{{ .Owner }}/{{ .Name }}</a>
66
+            {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
67
+            <span>{{ octicon "star" }} {{ .StarCount }}</span>
68
+          </li>
69
+          {{ end }}
70
+        </ol>
71
+        {{ else }}<p class="shithub-dashboard-empty">No public activity yet.</p>{{ end }}
72
+      </section>
73
+
74
+      <section class="shithub-side-panel">
75
+        <h2>{{ octicon "people" }} Developers</h2>
76
+        {{ if .TrendingUsers }}
77
+        <ol class="shithub-trending-user-list">
78
+          {{ range .TrendingUsers }}
79
+          <li>
80
+            <a href="/{{ .Username }}"><img src="/avatars/{{ .Username }}" alt="" width="24" height="24"> {{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a>
81
+            <span>@{{ .Username }}</span>
82
+          </li>
83
+          {{ end }}
84
+        </ol>
85
+        {{ else }}<p class="shithub-dashboard-empty">No developers trending yet.</p>{{ end }}
86
+      </section>
87
+    </aside>
88
+  </div>
89
+</section>
90
+{{- end }}