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 @@
1
 # Social Feed
1
 # Social Feed
2
 
2
 
3
 S42 turns the S26 social primitives into a GitHub-like network surface:
3
 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
5
 trending rankings.
5
 trending rankings.
6
 
6
 
7
 ## Follow Graph
7
 ## Follow Graph
@@ -33,7 +33,11 @@ repo-scoped. This second repo visibility check is load-bearing: an event
33
 emitted while a repo was public must not leak after the repo becomes
33
 emitted while a repo was public must not leak after the repo becomes
34
 private.
34
 private.
35
 
35
 
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:
37
 
41
 
38
 - the viewer's own public activity,
42
 - the viewer's own public activity,
39
 - public activity from followed users,
43
 - public activity from followed users,
@@ -41,8 +45,8 @@ The authenticated Home feed includes:
41
 - public activity from repos owned by followed orgs,
45
 - public activity from repos owned by followed orgs,
42
 - public org-scoped activity for followed orgs.
46
 - public org-scoped activity for followed orgs.
43
 
47
 
44
-Explore uses the global public feed. Both feeds page with a keyset
48
+Anonymous Explore uses the global public feed. Both feeds page with a
45
-cursor over `(created_at, id)`.
49
+keyset cursor over `(created_at, id)`.
46
 
50
 
47
 ## Event Kinds
51
 ## Event Kinds
48
 
52
 
@@ -56,6 +60,10 @@ Current feed sources include:
56
 - `pr_opened` and pull-request comment events
60
 - `pr_opened` and pull-request comment events
57
 - `followed_user` / `followed_org`
61
 - `followed_user` / `followed_org`
58
 
62
 
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
+
59
 The `kind` and `source_kind` columns remain text. New product surfaces
67
 The `kind` and `source_kind` columns remain text. New product surfaces
60
 can add events without a schema migration as long as their payload is
68
 can add events without a schema migration as long as their payload is
61
 small JSON and the public flag is set conservatively.
69
 small JSON and the public flag is set conservatively.
internal/social/feed.gomodified
@@ -56,6 +56,7 @@ type FeedItem struct {
56
 	Repo             *FeedRepo
56
 	Repo             *FeedRepo
57
 	RepoFullName     string
57
 	RepoFullName     string
58
 	RepoURL          string
58
 	RepoURL          string
59
+	SourceKind       string
59
 	SourceName       string
60
 	SourceName       string
60
 	SourceURL        string
61
 	SourceURL        string
61
 	ItemTitle        string
62
 	ItemTitle        string
@@ -64,6 +65,7 @@ type FeedItem struct {
64
 
65
 
65
 type DashboardRepo struct {
66
 type DashboardRepo struct {
66
 	ID              int64
67
 	ID              int64
68
+	Owner           string
67
 	Name            string
69
 	Name            string
68
 	Description     string
70
 	Description     string
69
 	Visibility      string
71
 	Visibility      string
@@ -143,12 +145,12 @@ func PublicFeed(ctx context.Context, deps Deps, cursor FeedCursor, limit int32)
143
 }
145
 }
144
 
146
 
145
 func DashboardRepos(ctx context.Context, deps Deps, viewerUserID int64, limit int32) ([]DashboardRepo, error) {
147
 func DashboardRepos(ctx context.Context, deps Deps, viewerUserID int64, limit int32) ([]DashboardRepo, error) {
146
-	if limit <= 0 || limit > 20 {
148
+	if limit <= 0 || limit > 50 {
147
-		limit = 8
149
+		limit = 20
148
 	}
150
 	}
149
 	rows, err := socialdb.New().ListDashboardReposForUser(ctx, deps.Pool, socialdb.ListDashboardReposForUserParams{
151
 	rows, err := socialdb.New().ListDashboardReposForUser(ctx, deps.Pool, socialdb.ListDashboardReposForUserParams{
150
-		OwnerUserID: pgtype.Int8{Int64: viewerUserID, Valid: true},
152
+		ViewerUserID: viewerUserID,
151
-		Limit:       limit,
153
+		LimitCount:   limit,
152
 	})
154
 	})
153
 	if err != nil {
155
 	if err != nil {
154
 		return nil, fmt.Errorf("dashboard repos: %w", err)
156
 		return nil, fmt.Errorf("dashboard repos: %w", err)
@@ -156,7 +158,7 @@ func DashboardRepos(ctx context.Context, deps Deps, viewerUserID int64, limit in
156
 	out := make([]DashboardRepo, 0, len(rows))
158
 	out := make([]DashboardRepo, 0, len(rows))
157
 	for _, row := range rows {
159
 	for _, row := range rows {
158
 		out = append(out, DashboardRepo{
160
 		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,
160
 			Visibility: string(row.Visibility), PrimaryLanguage: row.PrimaryLanguage,
162
 			Visibility: string(row.Visibility), PrimaryLanguage: row.PrimaryLanguage,
161
 			StarCount: row.StarCount, ForkCount: row.ForkCount,
163
 			StarCount: row.StarCount, ForkCount: row.ForkCount,
162
 			UpdatedAt: timeFromPG(row.UpdatedAt),
164
 			UpdatedAt: timeFromPG(row.UpdatedAt),
@@ -315,7 +317,7 @@ func feedItemFromDashboardRow(row socialdb.ListDashboardFeedEventsRow) FeedItem
315
 		repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
317
 		repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
316
 		repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
318
 		repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
317
 		repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
319
 		repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
318
-		sourceName: row.SourceName, payload: row.Payload,
320
+		sourceKind: row.SourceKind, sourceName: row.SourceName, payload: row.Payload,
319
 	})
321
 	})
320
 }
322
 }
321
 
323
 
@@ -326,7 +328,7 @@ func feedItemFromPublicRow(row socialdb.ListPublicFeedEventsRow) FeedItem {
326
 		repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
328
 		repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
327
 		repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
329
 		repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
328
 		repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
330
 		repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
329
-		sourceName: row.SourceName, payload: row.Payload,
331
+		sourceKind: row.SourceKind, sourceName: row.SourceName, payload: row.Payload,
330
 	})
332
 	})
331
 }
333
 }
332
 
334
 
@@ -343,6 +345,7 @@ type feedParts struct {
343
 	repoPrimaryLanguage string
345
 	repoPrimaryLanguage string
344
 	repoStarCount       int64
346
 	repoStarCount       int64
345
 	repoForkCount       int64
347
 	repoForkCount       int64
348
+	sourceKind          string
346
 	sourceName          string
349
 	sourceName          string
347
 	payload             []byte
350
 	payload             []byte
348
 }
351
 }
@@ -351,7 +354,7 @@ func feedItemFromParts(p feedParts) FeedItem {
351
 	item := FeedItem{
354
 	item := FeedItem{
352
 		ID: p.id, Kind: p.kind, Verb: feedVerb(p.kind),
355
 		ID: p.id, Kind: p.kind, Verb: feedVerb(p.kind),
353
 		ActorUsername: p.actorUsername, ActorDisplayName: p.actorDisplayName,
356
 		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,
355
 	}
358
 	}
356
 	if p.repoID.Valid && p.repoOwner != "" && p.repoName != "" {
359
 	if p.repoID.Valid && p.repoOwner != "" && p.repoName != "" {
357
 		item.Repo = &FeedRepo{
360
 		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
21
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
21
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
22
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
22
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
23
 WHERE de.public = true
23
 WHERE de.public = true
24
+  AND de.kind <> 'unstar'
24
   AND actor.suspended_at IS NULL
25
   AND actor.suspended_at IS NULL
25
   AND actor.deleted_at IS NULL
26
   AND actor.deleted_at IS NULL
26
   AND (
27
   AND (
@@ -86,6 +87,7 @@ LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
86
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
87
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
87
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
88
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
88
 WHERE de.public = true
89
 WHERE de.public = true
90
+  AND de.kind <> 'unstar'
89
   AND actor.suspended_at IS NULL
91
   AND actor.suspended_at IS NULL
90
   AND actor.deleted_at IS NULL
92
   AND actor.deleted_at IS NULL
91
   AND (
93
   AND (
@@ -172,6 +174,7 @@ LIMIT sqlc.arg(limit_count)::int;
172
 -- name: ListDashboardReposForUser :many
174
 -- name: ListDashboardReposForUser :many
173
 SELECT
175
 SELECT
174
     r.id AS repo_id,
176
     r.id AS repo_id,
177
+    COALESCE(owner_user.username::text, owner_org.slug::text, '')::text AS owner,
175
     r.name::text AS name,
178
     r.name::text AS name,
176
     r.description,
179
     r.description,
177
     r.visibility,
180
     r.visibility,
@@ -180,10 +183,18 @@ SELECT
180
     r.fork_count,
183
     r.fork_count,
181
     r.updated_at
184
     r.updated_at
182
 FROM repos r
185
 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
+  )
184
   AND r.deleted_at IS NULL
195
   AND r.deleted_at IS NULL
185
 ORDER BY r.updated_at DESC
196
 ORDER BY r.updated_at DESC
186
-LIMIT $2;
197
+LIMIT sqlc.arg(limit_count)::int;
187
 
198
 
188
 -- name: InsertTrendingSnapshot :one
199
 -- name: InsertTrendingSnapshot :one
189
 INSERT INTO trending_snapshots (scope, kind, payload)
200
 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
85
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
85
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
86
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
86
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
87
 WHERE de.public = true
87
 WHERE de.public = true
88
+  AND de.kind <> 'unstar'
88
   AND actor.suspended_at IS NULL
89
   AND actor.suspended_at IS NULL
89
   AND actor.deleted_at IS NULL
90
   AND actor.deleted_at IS NULL
90
   AND (
91
   AND (
@@ -206,6 +207,7 @@ func (q *Queries) ListDashboardFeedEvents(ctx context.Context, db DBTX, arg List
206
 const listDashboardReposForUser = `-- name: ListDashboardReposForUser :many
207
 const listDashboardReposForUser = `-- name: ListDashboardReposForUser :many
207
 SELECT
208
 SELECT
208
     r.id AS repo_id,
209
     r.id AS repo_id,
210
+    COALESCE(owner_user.username::text, owner_org.slug::text, '')::text AS owner,
209
     r.name::text AS name,
211
     r.name::text AS name,
210
     r.description,
212
     r.description,
211
     r.visibility,
213
     r.visibility,
@@ -214,19 +216,28 @@ SELECT
214
     r.fork_count,
216
     r.fork_count,
215
     r.updated_at
217
     r.updated_at
216
 FROM repos r
218
 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
+  )
218
   AND r.deleted_at IS NULL
228
   AND r.deleted_at IS NULL
219
 ORDER BY r.updated_at DESC
229
 ORDER BY r.updated_at DESC
220
-LIMIT $2
230
+LIMIT $2::int
221
 `
231
 `
222
 
232
 
223
 type ListDashboardReposForUserParams struct {
233
 type ListDashboardReposForUserParams struct {
224
-	OwnerUserID pgtype.Int8
234
+	ViewerUserID int64
225
-	Limit       int32
235
+	LimitCount   int32
226
 }
236
 }
227
 
237
 
228
 type ListDashboardReposForUserRow struct {
238
 type ListDashboardReposForUserRow struct {
229
 	RepoID          int64
239
 	RepoID          int64
240
+	Owner           string
230
 	Name            string
241
 	Name            string
231
 	Description     string
242
 	Description     string
232
 	Visibility      RepoVisibility
243
 	Visibility      RepoVisibility
@@ -237,7 +248,7 @@ type ListDashboardReposForUserRow struct {
237
 }
248
 }
238
 
249
 
239
 func (q *Queries) ListDashboardReposForUser(ctx context.Context, db DBTX, arg ListDashboardReposForUserParams) ([]ListDashboardReposForUserRow, error) {
250
 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)
241
 	if err != nil {
252
 	if err != nil {
242
 		return nil, err
253
 		return nil, err
243
 	}
254
 	}
@@ -247,6 +258,7 @@ func (q *Queries) ListDashboardReposForUser(ctx context.Context, db DBTX, arg Li
247
 		var i ListDashboardReposForUserRow
258
 		var i ListDashboardReposForUserRow
248
 		if err := rows.Scan(
259
 		if err := rows.Scan(
249
 			&i.RepoID,
260
 			&i.RepoID,
261
+			&i.Owner,
250
 			&i.Name,
262
 			&i.Name,
251
 			&i.Description,
263
 			&i.Description,
252
 			&i.Visibility,
264
 			&i.Visibility,
@@ -286,6 +298,7 @@ LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
286
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
298
 LEFT JOIN users source_user ON de.source_kind = 'user' AND source_user.id = de.source_id
287
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
299
 LEFT JOIN orgs source_org ON de.source_kind = 'org' AND source_org.id = de.source_id
288
 WHERE de.public = true
300
 WHERE de.public = true
301
+  AND de.kind <> 'unstar'
289
   AND actor.suspended_at IS NULL
302
   AND actor.suspended_at IS NULL
290
   AND actor.deleted_at IS NULL
303
   AND actor.deleted_at IS NULL
291
   AND (
304
   AND (
internal/web/handlers/explore.gomodified
@@ -11,7 +11,9 @@ import (
11
 
11
 
12
 	"github.com/jackc/pgx/v5/pgxpool"
12
 	"github.com/jackc/pgx/v5/pgxpool"
13
 
13
 
14
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
14
 	"github.com/tenseleyFlow/shithub/internal/social"
15
 	"github.com/tenseleyFlow/shithub/internal/social"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
15
 	"github.com/tenseleyFlow/shithub/internal/web/render"
17
 	"github.com/tenseleyFlow/shithub/internal/web/render"
16
 )
18
 )
17
 
19
 
@@ -32,18 +34,35 @@ func (h exploreHandler) ServeTrending(w http.ResponseWriter, r *http.Request) {
32
 }
34
 }
33
 
35
 
34
 func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, path, activeTab string) {
36
 func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, path, activeTab string) {
37
+	viewer := middleware.CurrentUserFromContext(r.Context())
35
 	var (
38
 	var (
36
 		feed          []social.FeedItem
39
 		feed          []social.FeedItem
37
 		hasNext       bool
40
 		hasNext       bool
38
 		nextURL       string
41
 		nextURL       string
42
+		topRepos      []social.DashboardRepo
43
+		viewerOrgs    []orgsdb.ListOrgsForUserRow
39
 		trendingRepos []social.TrendingRepo
44
 		trendingRepos []social.TrendingRepo
40
 		trendingUsers []social.TrendingUser
45
 		trendingUsers []social.TrendingUser
41
 	)
46
 	)
42
 	if h.pool != nil {
47
 	if h.pool != nil {
43
 		deps := social.Deps{Pool: h.pool, Logger: h.logger}
48
 		deps := social.Deps{Pool: h.pool, Logger: h.logger}
44
 		feed, hasNext, nextURL = feedPageFor(r, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
49
 		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
+			}
45
 			return social.PublicFeed(r.Context(), deps, cursor, limit)
53
 			return social.PublicFeed(r.Context(), deps, cursor, limit)
46
 		})
54
 		})
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
+		}
47
 		var err error
66
 		var err error
48
 		trendingRepos, err = social.CachedTrendingRepos(r.Context(), deps, social.TrendingScopeWeek, 7, 10)
67
 		trendingRepos, err = social.CachedTrendingRepos(r.Context(), deps, social.TrendingScopeWeek, 7, 10)
49
 		if err != nil && h.logger != nil {
68
 		if err != nil && h.logger != nil {
@@ -55,15 +74,34 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
55
 		}
74
 		}
56
 	}
75
 	}
57
 
76
 
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
+
58
 	data := map[string]any{
90
 	data := map[string]any{
59
-		"Title":         title,
91
+		"Title":          title,
60
-		"ActiveTab":     activeTab,
92
+		"ActiveTab":      activeTab,
61
-		"Feed":          feed,
93
+		"PageHeading":    pageHeading,
62
-		"FeedHasNext":   hasNext,
94
+		"FeedHeading":    feedHeading,
63
-		"FeedNextURL":   nextURL,
95
+		"FeedEmptyTitle": emptyTitle,
64
-		"TrendingRepos": trendingRepos,
96
+		"FeedEmptyBody":  emptyBody,
65
-		"TrendingUsers": trendingUsers,
97
+		"Feed":           feed,
66
-		"Path":          path,
98
+		"FeedHasNext":    hasNext,
99
+		"FeedNextURL":    nextURL,
100
+		"TopRepos":       topRepos,
101
+		"ViewerOrgs":     viewerOrgs,
102
+		"TrendingRepos":  trendingRepos,
103
+		"TrendingUsers":  trendingUsers,
104
+		"Path":           path,
67
 	}
105
 	}
68
 	if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
106
 	if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
69
 		if h.logger != nil {
107
 		if h.logger != nil {
internal/web/static/css/shithub.cssmodified
@@ -8573,6 +8573,10 @@ button.shithub-repo-action {
8573
   max-width: 1100px;
8573
   max-width: 1100px;
8574
   margin: 0 auto;
8574
   margin: 0 auto;
8575
 }
8575
 }
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
+}
8576
 .shithub-dashboard-left,
8580
 .shithub-dashboard-left,
8577
 .shithub-dashboard-right,
8581
 .shithub-dashboard-right,
8578
 .shithub-explore-right {
8582
 .shithub-explore-right {
@@ -8583,6 +8587,116 @@ button.shithub-repo-action {
8583
   top: 1rem;
8587
   top: 1rem;
8584
   align-self: start;
8588
   align-self: start;
8585
 }
8589
 }
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
+}
8586
 .shithub-dashboard-sidehead,
8700
 .shithub-dashboard-sidehead,
8587
 .shithub-feed-toolbar,
8701
 .shithub-feed-toolbar,
8588
 .shithub-explore-head {
8702
 .shithub-explore-head {
@@ -8626,6 +8740,9 @@ button.shithub-repo-action {
8626
   display: grid;
8740
   display: grid;
8627
   gap: 0.45rem;
8741
   gap: 0.45rem;
8628
 }
8742
 }
8743
+.shithub-dashboard-repo-list li[hidden] {
8744
+  display: none;
8745
+}
8629
 .shithub-dashboard-repo-list a,
8746
 .shithub-dashboard-repo-list a,
8630
 .shithub-trending-user-list a {
8747
 .shithub-trending-user-list a {
8631
   display: inline-flex;
8748
   display: inline-flex;
@@ -8635,6 +8752,12 @@ button.shithub-repo-action {
8635
   color: var(--fg-default);
8752
   color: var(--fg-default);
8636
   font-weight: 600;
8753
   font-weight: 600;
8637
 }
8754
 }
8755
+.shithub-dashboard-repo-list a {
8756
+  max-width: 100%;
8757
+  overflow: hidden;
8758
+  text-overflow: ellipsis;
8759
+  white-space: nowrap;
8760
+}
8638
 .shithub-dashboard-repo-list img,
8761
 .shithub-dashboard-repo-list img,
8639
 .shithub-trending-user-list img,
8762
 .shithub-trending-user-list img,
8640
 .shithub-feed-avatar img {
8763
 .shithub-feed-avatar img {
@@ -8703,6 +8826,36 @@ button.shithub-repo-action {
8703
   color: var(--fg-muted);
8826
   color: var(--fg-muted);
8704
   font-size: 0.875rem;
8827
   font-size: 0.875rem;
8705
 }
8828
 }
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
+}
8706
 .shithub-feed-meta {
8859
 .shithub-feed-meta {
8707
   display: flex;
8860
   display: flex;
8708
   flex-wrap: wrap;
8861
   flex-wrap: wrap;
@@ -8852,6 +9005,9 @@ button.shithub-repo-action {
8852
   .shithub-explore-shell {
9005
   .shithub-explore-shell {
8853
     grid-template-columns: 1fr;
9006
     grid-template-columns: 1fr;
8854
   }
9007
   }
9008
+  .shithub-explore-shell.has-dashboard-left {
9009
+    grid-template-columns: 1fr;
9010
+  }
8855
   .shithub-dashboard-left {
9011
   .shithub-dashboard-left {
8856
     position: static;
9012
     position: static;
8857
   }
9013
   }
@@ -8871,6 +9027,18 @@ button.shithub-repo-action {
8871
     width: 32px;
9027
     width: 32px;
8872
     height: 32px;
9028
     height: 32px;
8873
   }
9029
   }
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
+  }
8874
   .shithub-explore-head,
9042
   .shithub-explore-head,
8875
   .shithub-dashboard-sidehead,
9043
   .shithub-dashboard-sidehead,
8876
   .shithub-feed-toolbar {
9044
   .shithub-feed-toolbar {
internal/web/templates/_feed_row.htmlmodified
@@ -11,7 +11,18 @@
11
       {{ if and .RepoFullName (ne .ItemTitle .RepoFullName) }}<span>in</span> <a href="{{ .RepoURL }}" class="shithub-feed-target">{{ .RepoFullName }}</a>{{ 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>
12
       <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt }}</time>
13
     </p>
13
     </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 }}
15
     <div class="shithub-feed-repo">
26
     <div class="shithub-feed-repo">
16
       <a href="{{ .RepoURL }}" class="shithub-feed-repo-title">{{ octicon "repo" }} {{ .RepoFullName }}</a>
27
       <a href="{{ .RepoURL }}" class="shithub-feed-repo-title">{{ octicon "repo" }} {{ .RepoFullName }}</a>
17
       {{ if .Repo.Description }}<p>{{ .Repo.Description }}</p>{{ end }}
28
       {{ if .Repo.Description }}<p>{{ .Repo.Description }}</p>{{ end }}
internal/web/templates/_layout.htmlmodified
@@ -120,6 +120,23 @@
120
     }
120
     }
121
   })();
121
   })();
122
 
122
 
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
+
123
   (function () {
140
   (function () {
124
     var root = document.querySelector("[data-search-root]");
141
     var root = document.querySelector("[data-search-root]");
125
     if (!root) return;
142
     if (!root) return;
internal/web/templates/explore/index.htmlmodified
@@ -1,9 +1,68 @@
1
 {{ define "page" -}}
1
 {{ define "page" -}}
2
 <section class="shithub-explore-page">
2
 <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
+
4
     <main class="shithub-explore-main">
63
     <main class="shithub-explore-main">
5
       <div class="shithub-explore-head">
64
       <div class="shithub-explore-head">
6
-        <h1>{{ if eq .ActiveTab "trending" }}Trending{{ else }}Explore{{ end }}</h1>
65
+        <h1>{{ .PageHeading }}</h1>
7
         <nav class="shithub-explore-tabs" aria-label="Explore">
66
         <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>
67
           <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>
68
           <a href="/trending"{{ if eq .ActiveTab "trending" }} class="is-selected" aria-current="page"{{ end }}>{{ octicon "star" }} Trending</a>
@@ -34,8 +93,8 @@
34
       </section>
93
       </section>
35
       {{ else }}
94
       {{ else }}
36
       <div class="shithub-feed-toolbar">
95
       <div class="shithub-feed-toolbar">
37
-        <h2>Public activity</h2>
96
+        <h2>{{ .FeedHeading }}</h2>
38
-        <a class="shithub-button shithub-button-small" href="/trending">{{ octicon "star" }} Trending</a>
97
+        <button type="button" class="shithub-button shithub-button-small" disabled>{{ octicon "list-unordered" }} Filter</button>
39
       </div>
98
       </div>
40
       {{ if .Feed }}
99
       {{ if .Feed }}
41
       <ol class="shithub-feed-list">
100
       <ol class="shithub-feed-list">
@@ -43,8 +102,9 @@
43
       </ol>
102
       </ol>
44
       {{ else }}
103
       {{ else }}
45
       <div class="shithub-feed-empty">
104
       <div class="shithub-feed-empty">
46
-        <h2>{{ octicon "pulse" }} No public activity yet</h2>
105
+        <h2>{{ octicon "people" }} {{ .FeedEmptyTitle }}</h2>
47
-        <p>Public stars, forks, pushes, issues, pull requests, and follows will appear here.</p>
106
+        <p>{{ .FeedEmptyBody }}</p>
107
+        {{ if .Viewer.ID }}<a href="/trending" class="shithub-button">Explore trending repositories</a>{{ end }}
48
       </div>
108
       </div>
49
       {{ end }}
109
       {{ end }}
50
       {{ if .FeedHasNext }}
110
       {{ if .FeedHasNext }}
@@ -57,7 +117,7 @@
57
 
117
 
58
     <aside class="shithub-explore-right" aria-label="Trending">
118
     <aside class="shithub-explore-right" aria-label="Trending">
59
       <section class="shithub-side-panel">
119
       <section class="shithub-side-panel">
60
-        <h2>{{ octicon "star" }} Repositories</h2>
120
+        <h2>{{ octicon "pulse" }} Trending repositories</h2>
61
         {{ if .TrendingRepos }}
121
         {{ if .TrendingRepos }}
62
         <ol class="shithub-trending-mini-list">
122
         <ol class="shithub-trending-mini-list">
63
           {{ range .TrendingRepos }}
123
           {{ range .TrendingRepos }}
@@ -72,7 +132,7 @@
72
       </section>
132
       </section>
73
 
133
 
74
       <section class="shithub-side-panel">
134
       <section class="shithub-side-panel">
75
-        <h2>{{ octicon "people" }} Developers</h2>
135
+        <h2>{{ octicon "people" }} Trending developers</h2>
76
         {{ if .TrendingUsers }}
136
         {{ if .TrendingUsers }}
77
         <ol class="shithub-trending-user-list">
137
         <ol class="shithub-trending-user-list">
78
           {{ range .TrendingUsers }}
138
           {{ range .TrendingUsers }}