tenseleyflow/shithub / 180a690

Browse files

Align Explore with dashboard feed layout

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
180a690aa7f6894dc001940e4700ff3df5e0252a
Parents
fa20687
Tree
d4ffaa7

9 changed files

StatusFile+-
M docs/internal/social.md 12 4
M internal/social/feed.go 11 8
M internal/social/queries/feed.sql 13 2
M internal/social/sqlc/feed.sql.go 18 5
M internal/web/handlers/explore.go 46 8
M internal/web/static/css/shithub.css 168 0
M internal/web/templates/_feed_row.html 12 1
M internal/web/templates/_layout.html 17 0
M internal/web/templates/explore/index.html 68 8
docs/internal/social.mdmodified
@@ -1,7 +1,7 @@
11
 # Social Feed
22
 
33
 S42 turns the S26 social primitives into a GitHub-like network surface:
4
-follow graph, authenticated Home feed, public Explore feed, and cached
4
+follow graph, signed-in Explore/Home feed, public Explore feed, and cached
55
 trending rankings.
66
 
77
 ## Follow Graph
@@ -33,7 +33,11 @@ repo-scoped. This second repo visibility check is load-bearing: an event
3333
 emitted while a repo was public must not leak after the repo becomes
3434
 private.
3535
 
36
-The authenticated Home feed includes:
36
+After sign-in, the default destination is `/explore`. `/` remains the
37
+public build/landing page so the top-left shithub brand is always a way
38
+back to the instance/version stamp.
39
+
40
+The signed-in `/explore` feed includes:
3741
 
3842
 - the viewer's own public activity,
3943
 - public activity from followed users,
@@ -41,8 +45,8 @@ The authenticated Home feed includes:
4145
 - public activity from repos owned by followed orgs,
4246
 - public org-scoped activity for followed orgs.
4347
 
44
-Explore uses the global public feed. Both feeds page with a keyset
45
-cursor over `(created_at, id)`.
48
+Anonymous Explore uses the global public feed. Both feeds page with a
49
+keyset cursor over `(created_at, id)`.
4650
 
4751
 ## Event Kinds
4852
 
@@ -56,6 +60,10 @@ Current feed sources include:
5660
 - `pr_opened` and pull-request comment events
5761
 - `followed_user` / `followed_org`
5862
 
63
+`unstar` events remain in `domain_events` for audit/product history, but
64
+the feed queries suppress them because GitHub does not surface unstars as
65
+activity feed stories.
66
+
5967
 The `kind` and `source_kind` columns remain text. New product surfaces
6068
 can add events without a schema migration as long as their payload is
6169
 small JSON and the public flag is set conservatively.
internal/social/feed.gomodified
@@ -56,6 +56,7 @@ type FeedItem struct {
5656
 	Repo             *FeedRepo
5757
 	RepoFullName     string
5858
 	RepoURL          string
59
+	SourceKind       string
5960
 	SourceName       string
6061
 	SourceURL        string
6162
 	ItemTitle        string
@@ -64,6 +65,7 @@ type FeedItem struct {
6465
 
6566
 type DashboardRepo struct {
6667
 	ID              int64
68
+	Owner           string
6769
 	Name            string
6870
 	Description     string
6971
 	Visibility      string
@@ -143,12 +145,12 @@ func PublicFeed(ctx context.Context, deps Deps, cursor FeedCursor, limit int32)
143145
 }
144146
 
145147
 func DashboardRepos(ctx context.Context, deps Deps, viewerUserID int64, limit int32) ([]DashboardRepo, error) {
146
-	if limit <= 0 || limit > 20 {
147
-		limit = 8
148
+	if limit <= 0 || limit > 50 {
149
+		limit = 20
148150
 	}
149151
 	rows, err := socialdb.New().ListDashboardReposForUser(ctx, deps.Pool, socialdb.ListDashboardReposForUserParams{
150
-		OwnerUserID: pgtype.Int8{Int64: viewerUserID, Valid: true},
151
-		Limit:       limit,
152
+		ViewerUserID: viewerUserID,
153
+		LimitCount:   limit,
152154
 	})
153155
 	if err != nil {
154156
 		return nil, fmt.Errorf("dashboard repos: %w", err)
@@ -156,7 +158,7 @@ func DashboardRepos(ctx context.Context, deps Deps, viewerUserID int64, limit in
156158
 	out := make([]DashboardRepo, 0, len(rows))
157159
 	for _, row := range rows {
158160
 		out = append(out, DashboardRepo{
159
-			ID: row.RepoID, Name: row.Name, Description: row.Description,
161
+			ID: row.RepoID, Owner: row.Owner, Name: row.Name, Description: row.Description,
160162
 			Visibility: string(row.Visibility), PrimaryLanguage: row.PrimaryLanguage,
161163
 			StarCount: row.StarCount, ForkCount: row.ForkCount,
162164
 			UpdatedAt: timeFromPG(row.UpdatedAt),
@@ -315,7 +317,7 @@ func feedItemFromDashboardRow(row socialdb.ListDashboardFeedEventsRow) FeedItem
315317
 		repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
316318
 		repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
317319
 		repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
318
-		sourceName: row.SourceName, payload: row.Payload,
320
+		sourceKind: row.SourceKind, sourceName: row.SourceName, payload: row.Payload,
319321
 	})
320322
 }
321323
 
@@ -326,7 +328,7 @@ func feedItemFromPublicRow(row socialdb.ListPublicFeedEventsRow) FeedItem {
326328
 		repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
327329
 		repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
328330
 		repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
329
-		sourceName: row.SourceName, payload: row.Payload,
331
+		sourceKind: row.SourceKind, sourceName: row.SourceName, payload: row.Payload,
330332
 	})
331333
 }
332334
 
@@ -343,6 +345,7 @@ type feedParts struct {
343345
 	repoPrimaryLanguage string
344346
 	repoStarCount       int64
345347
 	repoForkCount       int64
348
+	sourceKind          string
346349
 	sourceName          string
347350
 	payload             []byte
348351
 }
@@ -351,7 +354,7 @@ func feedItemFromParts(p feedParts) FeedItem {
351354
 	item := FeedItem{
352355
 		ID: p.id, Kind: p.kind, Verb: feedVerb(p.kind),
353356
 		ActorUsername: p.actorUsername, ActorDisplayName: p.actorDisplayName,
354
-		CreatedAt: timeFromPG(p.createdAt), SourceName: p.sourceName,
357
+		CreatedAt: timeFromPG(p.createdAt), SourceKind: p.sourceKind, SourceName: p.sourceName,
355358
 	}
356359
 	if p.repoID.Valid && p.repoOwner != "" && p.repoName != "" {
357360
 		item.Repo = &FeedRepo{
internal/social/queries/feed.sqlmodified
@@ -21,6 +21,7 @@ LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
2121
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
2222
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
2323
 WHERE de.public = true
24
+  AND de.kind <> 'unstar'
2425
   AND actor.suspended_at IS NULL
2526
   AND actor.deleted_at IS NULL
2627
   AND (
@@ -86,6 +87,7 @@ LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
8687
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
8788
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
8889
 WHERE de.public = true
90
+  AND de.kind <> 'unstar'
8991
   AND actor.suspended_at IS NULL
9092
   AND actor.deleted_at IS NULL
9193
   AND (
@@ -172,6 +174,7 @@ LIMIT sqlc.arg(limit_count)::int;
172174
 -- name: ListDashboardReposForUser :many
173175
 SELECT
174176
     r.id AS repo_id,
177
+    COALESCE(owner_user.username::text, owner_org.slug::text, '')::text AS owner,
175178
     r.name::text AS name,
176179
     r.description,
177180
     r.visibility,
@@ -180,10 +183,18 @@ SELECT
180183
     r.fork_count,
181184
     r.updated_at
182185
 FROM repos r
183
-WHERE r.owner_user_id = $1
186
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
187
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
188
+WHERE (
189
+      r.owner_user_id = sqlc.arg(viewer_user_id)::bigint
190
+      OR r.owner_org_id IN (
191
+          SELECT org_id FROM org_members
192
+          WHERE user_id = sqlc.arg(viewer_user_id)::bigint
193
+      )
194
+  )
184195
   AND r.deleted_at IS NULL
185196
 ORDER BY r.updated_at DESC
186
-LIMIT $2;
197
+LIMIT sqlc.arg(limit_count)::int;
187198
 
188199
 -- name: InsertTrendingSnapshot :one
189200
 INSERT INTO trending_snapshots (scope, kind, payload)
internal/social/sqlc/feed.sql.gomodified
@@ -85,6 +85,7 @@ LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
8585
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
8686
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
8787
 WHERE de.public = true
88
+  AND de.kind <> 'unstar'
8889
   AND actor.suspended_at IS NULL
8990
   AND actor.deleted_at IS NULL
9091
   AND (
@@ -206,6 +207,7 @@ func (q *Queries) ListDashboardFeedEvents(ctx context.Context, db DBTX, arg List
206207
 const listDashboardReposForUser = `-- name: ListDashboardReposForUser :many
207208
 SELECT
208209
     r.id AS repo_id,
210
+    COALESCE(owner_user.username::text, owner_org.slug::text, '')::text AS owner,
209211
     r.name::text AS name,
210212
     r.description,
211213
     r.visibility,
@@ -214,19 +216,28 @@ SELECT
214216
     r.fork_count,
215217
     r.updated_at
216218
 FROM repos r
217
-WHERE r.owner_user_id = $1
219
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
220
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
221
+WHERE (
222
+      r.owner_user_id = $1::bigint
223
+      OR r.owner_org_id IN (
224
+          SELECT org_id FROM org_members
225
+          WHERE user_id = $1::bigint
226
+      )
227
+  )
218228
   AND r.deleted_at IS NULL
219229
 ORDER BY r.updated_at DESC
220
-LIMIT $2
230
+LIMIT $2::int
221231
 `
222232
 
223233
 type ListDashboardReposForUserParams struct {
224
-	OwnerUserID pgtype.Int8
225
-	Limit       int32
234
+	ViewerUserID int64
235
+	LimitCount   int32
226236
 }
227237
 
228238
 type ListDashboardReposForUserRow struct {
229239
 	RepoID          int64
240
+	Owner           string
230241
 	Name            string
231242
 	Description     string
232243
 	Visibility      RepoVisibility
@@ -237,7 +248,7 @@ type ListDashboardReposForUserRow struct {
237248
 }
238249
 
239250
 func (q *Queries) ListDashboardReposForUser(ctx context.Context, db DBTX, arg ListDashboardReposForUserParams) ([]ListDashboardReposForUserRow, error) {
240
-	rows, err := db.Query(ctx, listDashboardReposForUser, arg.OwnerUserID, arg.Limit)
251
+	rows, err := db.Query(ctx, listDashboardReposForUser, arg.ViewerUserID, arg.LimitCount)
241252
 	if err != nil {
242253
 		return nil, err
243254
 	}
@@ -247,6 +258,7 @@ func (q *Queries) ListDashboardReposForUser(ctx context.Context, db DBTX, arg Li
247258
 		var i ListDashboardReposForUserRow
248259
 		if err := rows.Scan(
249260
 			&i.RepoID,
261
+			&i.Owner,
250262
 			&i.Name,
251263
 			&i.Description,
252264
 			&i.Visibility,
@@ -286,6 +298,7 @@ LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
286298
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
287299
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
288300
 WHERE de.public = true
301
+  AND de.kind <> 'unstar'
289302
   AND actor.suspended_at IS NULL
290303
   AND actor.deleted_at IS NULL
291304
   AND (
internal/web/handlers/explore.gomodified
@@ -11,7 +11,9 @@ import (
1111
 
1212
 	"github.com/jackc/pgx/v5/pgxpool"
1313
 
14
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
1415
 	"github.com/tenseleyFlow/shithub/internal/social"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1517
 	"github.com/tenseleyFlow/shithub/internal/web/render"
1618
 )
1719
 
@@ -32,18 +34,35 @@ func (h exploreHandler) ServeTrending(w http.ResponseWriter, r *http.Request) {
3234
 }
3335
 
3436
 func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, path, activeTab string) {
37
+	viewer := middleware.CurrentUserFromContext(r.Context())
3538
 	var (
3639
 		feed          []social.FeedItem
3740
 		hasNext       bool
3841
 		nextURL       string
42
+		topRepos      []social.DashboardRepo
43
+		viewerOrgs    []orgsdb.ListOrgsForUserRow
3944
 		trendingRepos []social.TrendingRepo
4045
 		trendingUsers []social.TrendingUser
4146
 	)
4247
 	if h.pool != nil {
4348
 		deps := social.Deps{Pool: h.pool, Logger: h.logger}
4449
 		feed, hasNext, nextURL = feedPageFor(r, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
50
+			if viewer.ID != 0 {
51
+				return social.DashboardFeed(r.Context(), deps, viewer.ID, cursor, limit)
52
+			}
4553
 			return social.PublicFeed(r.Context(), deps, cursor, limit)
4654
 		})
55
+		if viewer.ID != 0 {
56
+			var err error
57
+			topRepos, err = social.DashboardRepos(r.Context(), deps, viewer.ID, 30)
58
+			if err != nil && h.logger != nil {
59
+				h.logger.WarnContext(r.Context(), "explore dashboard repos", "error", err)
60
+			}
61
+			viewerOrgs, err = orgsdb.New().ListOrgsForUser(r.Context(), h.pool, viewer.ID)
62
+			if err != nil && h.logger != nil {
63
+				h.logger.WarnContext(r.Context(), "explore org switcher", "error", err)
64
+			}
65
+		}
4766
 		var err error
4867
 		trendingRepos, err = social.CachedTrendingRepos(r.Context(), deps, social.TrendingScopeWeek, 7, 10)
4968
 		if err != nil && h.logger != nil {
@@ -55,15 +74,34 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
5574
 		}
5675
 	}
5776
 
77
+	pageHeading := title
78
+	feedHeading := "Public activity"
79
+	emptyTitle := "No public activity yet"
80
+	emptyBody := "Public stars, forks, pushes, issues, pull requests, and follows will appear here."
81
+	if viewer.ID != 0 {
82
+		if activeTab == "activity" {
83
+			pageHeading = "Home"
84
+		}
85
+		feedHeading = "Feed"
86
+		emptyTitle = "Follow people and organizations to build your feed"
87
+		emptyBody = "Stars, forks, pushes, issues, pull requests, and follows from your network will appear here."
88
+	}
89
+
5890
 	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,
91
+		"Title":          title,
92
+		"ActiveTab":      activeTab,
93
+		"PageHeading":    pageHeading,
94
+		"FeedHeading":    feedHeading,
95
+		"FeedEmptyTitle": emptyTitle,
96
+		"FeedEmptyBody":  emptyBody,
97
+		"Feed":           feed,
98
+		"FeedHasNext":    hasNext,
99
+		"FeedNextURL":    nextURL,
100
+		"TopRepos":       topRepos,
101
+		"ViewerOrgs":     viewerOrgs,
102
+		"TrendingRepos":  trendingRepos,
103
+		"TrendingUsers":  trendingUsers,
104
+		"Path":           path,
67105
 	}
68106
 	if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
69107
 		if h.logger != nil {
internal/web/static/css/shithub.cssmodified
@@ -8573,6 +8573,10 @@ button.shithub-repo-action {
85738573
   max-width: 1100px;
85748574
   margin: 0 auto;
85758575
 }
8576
+.shithub-explore-shell.has-dashboard-left {
8577
+  grid-template-columns: minmax(220px, 296px) minmax(0, 1fr) minmax(220px, 296px);
8578
+  max-width: 1280px;
8579
+}
85768580
 .shithub-dashboard-left,
85778581
 .shithub-dashboard-right,
85788582
 .shithub-explore-right {
@@ -8583,6 +8587,116 @@ button.shithub-repo-action {
85838587
   top: 1rem;
85848588
   align-self: start;
85858589
 }
8590
+.shithub-dashboard-identity {
8591
+  position: relative;
8592
+  margin-bottom: 2rem;
8593
+}
8594
+.shithub-dashboard-identity summary {
8595
+  display: inline-flex;
8596
+  align-items: center;
8597
+  gap: 0.55rem;
8598
+  max-width: 100%;
8599
+  color: var(--fg-default);
8600
+  font-weight: 600;
8601
+  cursor: pointer;
8602
+  list-style: none;
8603
+}
8604
+.shithub-dashboard-identity summary::-webkit-details-marker {
8605
+  display: none;
8606
+}
8607
+.shithub-dashboard-identity summary img,
8608
+.shithub-dashboard-org-list img {
8609
+  border-radius: 50%;
8610
+}
8611
+.shithub-dashboard-identity summary span {
8612
+  min-width: 0;
8613
+  overflow: hidden;
8614
+  text-overflow: ellipsis;
8615
+}
8616
+.shithub-dashboard-identity-menu {
8617
+  position: absolute;
8618
+  top: calc(100% + 0.65rem);
8619
+  left: 0;
8620
+  z-index: 20;
8621
+  width: min(320px, calc(100vw - 2rem));
8622
+  padding: 0.75rem;
8623
+  border: 1px solid var(--border-default);
8624
+  border-radius: 8px;
8625
+  background: var(--canvas-default);
8626
+  box-shadow: 0 16px 32px rgba(1, 4, 9, 0.35);
8627
+}
8628
+.shithub-dashboard-identity-title {
8629
+  padding: 0.25rem 0.35rem 0.65rem;
8630
+  color: var(--fg-default);
8631
+  font-weight: 600;
8632
+}
8633
+.shithub-dashboard-org-search {
8634
+  display: flex;
8635
+  align-items: center;
8636
+  gap: 0.5rem;
8637
+  margin: 0 0 0.65rem;
8638
+  padding: 0 0.65rem;
8639
+  border: 1px solid var(--border-default);
8640
+  border-radius: 6px;
8641
+  background: var(--canvas-subtle);
8642
+  color: var(--fg-muted);
8643
+}
8644
+.shithub-dashboard-org-search input {
8645
+  width: 100%;
8646
+  min-width: 0;
8647
+  padding: 0.45rem 0;
8648
+  border: 0;
8649
+  box-shadow: none;
8650
+  background: transparent;
8651
+}
8652
+.shithub-dashboard-org-search input:focus {
8653
+  box-shadow: none;
8654
+}
8655
+.shithub-dashboard-org-list {
8656
+  display: grid;
8657
+  gap: 0.15rem;
8658
+  max-height: 18rem;
8659
+  padding: 0;
8660
+  margin: 0 0 0.65rem;
8661
+  overflow: auto;
8662
+  list-style: none;
8663
+}
8664
+.shithub-dashboard-org-list a {
8665
+  display: grid;
8666
+  grid-template-columns: 16px 20px minmax(0, 1fr);
8667
+  align-items: center;
8668
+  gap: 0.55rem;
8669
+  padding: 0.35rem;
8670
+  border-radius: 6px;
8671
+  color: var(--fg-default);
8672
+  text-decoration: none;
8673
+}
8674
+.shithub-dashboard-org-list a:hover,
8675
+.shithub-dashboard-identity-actions a:hover {
8676
+  background: var(--canvas-subtle);
8677
+  text-decoration: none;
8678
+}
8679
+.shithub-dashboard-org-list span:last-child {
8680
+  min-width: 0;
8681
+  overflow: hidden;
8682
+  text-overflow: ellipsis;
8683
+  white-space: nowrap;
8684
+}
8685
+.shithub-dashboard-identity-actions {
8686
+  display: grid;
8687
+  gap: 0.5rem;
8688
+}
8689
+.shithub-dashboard-identity-actions a {
8690
+  display: inline-flex;
8691
+  align-items: center;
8692
+  justify-content: center;
8693
+  gap: 0.45rem;
8694
+  min-height: 2.25rem;
8695
+  border: 1px solid var(--border-default);
8696
+  border-radius: 6px;
8697
+  color: var(--fg-default);
8698
+  font-weight: 600;
8699
+}
85868700
 .shithub-dashboard-sidehead,
85878701
 .shithub-feed-toolbar,
85888702
 .shithub-explore-head {
@@ -8626,6 +8740,9 @@ button.shithub-repo-action {
86268740
   display: grid;
86278741
   gap: 0.45rem;
86288742
 }
8743
+.shithub-dashboard-repo-list li[hidden] {
8744
+  display: none;
8745
+}
86298746
 .shithub-dashboard-repo-list a,
86308747
 .shithub-trending-user-list a {
86318748
   display: inline-flex;
@@ -8635,6 +8752,12 @@ button.shithub-repo-action {
86358752
   color: var(--fg-default);
86368753
   font-weight: 600;
86378754
 }
8755
+.shithub-dashboard-repo-list a {
8756
+  max-width: 100%;
8757
+  overflow: hidden;
8758
+  text-overflow: ellipsis;
8759
+  white-space: nowrap;
8760
+}
86388761
 .shithub-dashboard-repo-list img,
86398762
 .shithub-trending-user-list img,
86408763
 .shithub-feed-avatar img {
@@ -8703,6 +8826,36 @@ button.shithub-repo-action {
87038826
   color: var(--fg-muted);
87048827
   font-size: 0.875rem;
87058828
 }
8829
+.shithub-feed-profile {
8830
+  display: grid;
8831
+  grid-template-columns: 44px minmax(0, 1fr) auto;
8832
+  align-items: center;
8833
+  gap: 0.85rem;
8834
+  margin-top: 0.8rem;
8835
+  padding: 0.85rem;
8836
+  border-radius: 6px;
8837
+  background: var(--canvas-subtle);
8838
+}
8839
+.shithub-feed-profile-avatar,
8840
+.shithub-feed-profile-avatar img {
8841
+  width: 44px;
8842
+  height: 44px;
8843
+  border-radius: 50%;
8844
+}
8845
+.shithub-feed-profile-copy {
8846
+  min-width: 0;
8847
+}
8848
+.shithub-feed-profile-name {
8849
+  display: block;
8850
+  color: var(--fg-default);
8851
+  font-weight: 600;
8852
+}
8853
+.shithub-feed-profile-copy span {
8854
+  display: block;
8855
+  margin-top: 0.15rem;
8856
+  color: var(--fg-muted);
8857
+  font-size: 0.875rem;
8858
+}
87068859
 .shithub-feed-meta {
87078860
   display: flex;
87088861
   flex-wrap: wrap;
@@ -8852,6 +9005,9 @@ button.shithub-repo-action {
88529005
   .shithub-explore-shell {
88539006
     grid-template-columns: 1fr;
88549007
   }
9008
+  .shithub-explore-shell.has-dashboard-left {
9009
+    grid-template-columns: 1fr;
9010
+  }
88559011
   .shithub-dashboard-left {
88569012
     position: static;
88579013
   }
@@ -8871,6 +9027,18 @@ button.shithub-repo-action {
88719027
     width: 32px;
88729028
     height: 32px;
88739029
   }
9030
+  .shithub-feed-profile {
9031
+    grid-template-columns: 36px minmax(0, 1fr);
9032
+  }
9033
+  .shithub-feed-profile .shithub-button {
9034
+    grid-column: 2;
9035
+    justify-self: start;
9036
+  }
9037
+  .shithub-feed-profile-avatar,
9038
+  .shithub-feed-profile-avatar img {
9039
+    width: 36px;
9040
+    height: 36px;
9041
+  }
88749042
   .shithub-explore-head,
88759043
   .shithub-dashboard-sidehead,
88769044
   .shithub-feed-toolbar {
internal/web/templates/_feed_row.htmlmodified
@@ -11,7 +11,18 @@
1111
       {{ if and .RepoFullName (ne .ItemTitle .RepoFullName) }}<span>in</span> <a href="{{ .RepoURL }}" class="shithub-feed-target">{{ .RepoFullName }}</a>{{ end }}
1212
       <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt }}</time>
1313
     </p>
14
-    {{ if .Repo }}
14
+    {{ if and .SourceName (or (eq .Kind "followed_user") (eq .Kind "followed_org")) }}
15
+    <div class="shithub-feed-profile">
16
+      <a href="{{ .SourceURL }}" class="shithub-feed-profile-avatar">
17
+        <img src="/avatars/{{ .SourceName }}" alt="" width="44" height="44">
18
+      </a>
19
+      <div class="shithub-feed-profile-copy">
20
+        <a href="{{ .SourceURL }}" class="shithub-feed-profile-name">{{ .SourceName }}</a>
21
+        <span>{{ if eq .SourceKind "org" }}Organization{{ else }}@{{ .SourceName }}{{ end }}</span>
22
+      </div>
23
+      <a href="{{ .SourceURL }}" class="shithub-button shithub-button-small">View</a>
24
+    </div>
25
+    {{ else if .Repo }}
1526
     <div class="shithub-feed-repo">
1627
       <a href="{{ .RepoURL }}" class="shithub-feed-repo-title">{{ octicon "repo" }} {{ .RepoFullName }}</a>
1728
       {{ if .Repo.Description }}<p>{{ .Repo.Description }}</p>{{ end }}
internal/web/templates/_layout.htmlmodified
@@ -120,6 +120,23 @@
120120
     }
121121
   })();
122122
 
123
+  (function () {
124
+    var input = document.querySelector("[data-dashboard-repo-filter]");
125
+    var rows = Array.prototype.slice.call(document.querySelectorAll("[data-dashboard-repo-row]"));
126
+    if (!input || rows.length === 0) return;
127
+
128
+    function applyFilter() {
129
+      var query = input.value.trim().toLowerCase();
130
+      rows.forEach(function (row) {
131
+        var name = (row.getAttribute("data-repo-name") || "").toLowerCase();
132
+        row.hidden = query !== "" && name.indexOf(query) === -1;
133
+      });
134
+    }
135
+
136
+    input.addEventListener("input", applyFilter);
137
+    applyFilter();
138
+  })();
139
+
123140
   (function () {
124141
     var root = document.querySelector("[data-search-root]");
125142
     if (!root) return;
internal/web/templates/explore/index.htmlmodified
@@ -1,9 +1,68 @@
11
 {{ define "page" -}}
22
 <section class="shithub-explore-page">
3
-  <div class="shithub-explore-shell">
3
+  <div class="shithub-explore-shell{{ if .Viewer.ID }} has-dashboard-left{{ end }}">
4
+    {{ if .Viewer.ID }}
5
+    <aside class="shithub-dashboard-left" aria-label="Dashboard navigation">
6
+      <details class="shithub-dashboard-identity">
7
+        <summary>
8
+          <img src="/avatars/{{ .Viewer.Username }}" alt="" width="20" height="20">
9
+          <span>{{ .Viewer.Username }}</span>
10
+          {{ octicon "triangle-down" }}
11
+        </summary>
12
+        <div class="shithub-dashboard-identity-menu">
13
+          <div class="shithub-dashboard-identity-title">Go to organization dashboard</div>
14
+          <label class="shithub-dashboard-org-search">
15
+            <span>{{ octicon "search" }}</span>
16
+            <input type="search" aria-label="Find an organization" autocomplete="off">
17
+          </label>
18
+          <ol class="shithub-dashboard-org-list">
19
+            <li>
20
+              <a href="/{{ .Viewer.Username }}">
21
+                {{ octicon "check" }}
22
+                <img src="/avatars/{{ .Viewer.Username }}" alt="" width="20" height="20">
23
+                <span>{{ .Viewer.Username }}</span>
24
+              </a>
25
+            </li>
26
+            {{ range .ViewerOrgs }}
27
+            <li>
28
+              <a href="/{{ .Slug }}">
29
+                <span aria-hidden="true"></span>
30
+                <img src="/avatars/{{ .Slug }}" alt="" width="20" height="20">
31
+                <span>{{ .Slug }}</span>
32
+              </a>
33
+            </li>
34
+            {{ end }}
35
+          </ol>
36
+          <div class="shithub-dashboard-identity-actions">
37
+            <a href="/settings/organizations">{{ octicon "organization" }} Manage organizations</a>
38
+            <a href="/organizations/new">{{ octicon "plus" }} Create organization</a>
39
+          </div>
40
+        </div>
41
+      </details>
42
+
43
+      <div class="shithub-dashboard-sidehead">
44
+        <h2>Top repositories</h2>
45
+        <a href="/new" class="shithub-button shithub-button-primary shithub-button-small">{{ octicon "repo" }} New</a>
46
+      </div>
47
+      <label class="sr-only" for="dashboard-repo-filter">Find a repository</label>
48
+      <input id="dashboard-repo-filter" class="shithub-dashboard-filter" type="search" placeholder="Find a repository..." autocomplete="off" data-dashboard-repo-filter>
49
+      {{ if .TopRepos }}
50
+      <ol class="shithub-dashboard-repo-list" data-dashboard-repo-list>
51
+        {{ range .TopRepos }}
52
+        <li data-dashboard-repo-row data-repo-name="{{ .Owner }}/{{ .Name }}">
53
+          <a href="/{{ .Owner }}/{{ .Name }}"><img src="/avatars/{{ .Owner }}" alt="" width="18" height="18"> {{ .Owner }}/{{ .Name }}</a>
54
+        </li>
55
+        {{ end }}
56
+      </ol>
57
+      {{ else }}
58
+      <p class="shithub-dashboard-empty">No repositories yet.</p>
59
+      {{ end }}
60
+    </aside>
61
+    {{ end }}
62
+
463
     <main class="shithub-explore-main">
564
       <div class="shithub-explore-head">
6
-        <h1>{{ if eq .ActiveTab "trending" }}Trending{{ else }}Explore{{ end }}</h1>
65
+        <h1>{{ .PageHeading }}</h1>
766
         <nav class="shithub-explore-tabs" aria-label="Explore">
867
           <a href="/explore"{{ if eq .ActiveTab "activity" }} class="is-selected" aria-current="page"{{ end }}>{{ octicon "pulse" }} Activity</a>
968
           <a href="/trending"{{ if eq .ActiveTab "trending" }} class="is-selected" aria-current="page"{{ end }}>{{ octicon "star" }} Trending</a>
@@ -34,8 +93,8 @@
3493
       </section>
3594
       {{ else }}
3695
       <div class="shithub-feed-toolbar">
37
-        <h2>Public activity</h2>
38
-        <a class="shithub-button shithub-button-small" href="/trending">{{ octicon "star" }} Trending</a>
96
+        <h2>{{ .FeedHeading }}</h2>
97
+        <button type="button" class="shithub-button shithub-button-small" disabled>{{ octicon "list-unordered" }} Filter</button>
3998
       </div>
4099
       {{ if .Feed }}
41100
       <ol class="shithub-feed-list">
@@ -43,8 +102,9 @@
43102
       </ol>
44103
       {{ else }}
45104
       <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>
105
+        <h2>{{ octicon "people" }} {{ .FeedEmptyTitle }}</h2>
106
+        <p>{{ .FeedEmptyBody }}</p>
107
+        {{ if .Viewer.ID }}<a href="/trending" class="shithub-button">Explore trending repositories</a>{{ end }}
48108
       </div>
49109
       {{ end }}
50110
       {{ if .FeedHasNext }}
@@ -57,7 +117,7 @@
57117
 
58118
     <aside class="shithub-explore-right" aria-label="Trending">
59119
       <section class="shithub-side-panel">
60
-        <h2>{{ octicon "star" }} Repositories</h2>
120
+        <h2>{{ octicon "pulse" }} Trending repositories</h2>
61121
         {{ if .TrendingRepos }}
62122
         <ol class="shithub-trending-mini-list">
63123
           {{ range .TrendingRepos }}
@@ -72,7 +132,7 @@
72132
       </section>
73133
 
74134
       <section class="shithub-side-panel">
75
-        <h2>{{ octicon "people" }} Developers</h2>
135
+        <h2>{{ octicon "people" }} Trending developers</h2>
76136
         {{ if .TrendingUsers }}
77137
         <ol class="shithub-trending-user-list">
78138
           {{ range .TrendingUsers }}