Add global dashboard list surfaces
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
949c68a0cfe8df9efffd526a45c062dda5db954a- Parents
-
0d477a4 - Tree
a30134f
949c68a
949c68a0cfe8df9efffd526a45c062dda5db954a0d477a4
a30134finternal/web/handlers/global_dashboard.goadded@@ -0,0 +1,692 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package handlers | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "fmt" | |
| 8 | + "log/slog" | |
| 9 | + "net/http" | |
| 10 | + "net/url" | |
| 11 | + "strconv" | |
| 12 | + "strings" | |
| 13 | + "time" | |
| 14 | + | |
| 15 | + "github.com/go-chi/chi/v5" | |
| 16 | + "github.com/jackc/pgx/v5/pgxpool" | |
| 17 | + | |
| 18 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" | |
| 19 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | |
| 20 | + "github.com/tenseleyFlow/shithub/internal/web/render" | |
| 21 | +) | |
| 22 | + | |
| 23 | +const globalDashboardLimit = 50 | |
| 24 | + | |
| 25 | +type globalNavHandler struct { | |
| 26 | + render *render.Renderer | |
| 27 | + logger *slog.Logger | |
| 28 | + pool *pgxpool.Pool | |
| 29 | +} | |
| 30 | + | |
| 31 | +type globalNavTab struct { | |
| 32 | + Key string | |
| 33 | + Label string | |
| 34 | + Icon string | |
| 35 | + Href string | |
| 36 | + Selected bool | |
| 37 | + Count int64 | |
| 38 | + Meta string | |
| 39 | +} | |
| 40 | + | |
| 41 | +type globalIssueCounts struct { | |
| 42 | + Total int64 | |
| 43 | + Open int64 | |
| 44 | + Closed int64 | |
| 45 | +} | |
| 46 | + | |
| 47 | +type globalIssueRow struct { | |
| 48 | + ID int64 | |
| 49 | + Owner string | |
| 50 | + RepoName string | |
| 51 | + Number int64 | |
| 52 | + Title string | |
| 53 | + State string | |
| 54 | + Kind string | |
| 55 | + AuthorName string | |
| 56 | + URL string | |
| 57 | + RepoURL string | |
| 58 | + UpdatedAt time.Time | |
| 59 | + CommentCount int64 | |
| 60 | +} | |
| 61 | + | |
| 62 | +type globalRepoRow struct { | |
| 63 | + ID int64 | |
| 64 | + Owner string | |
| 65 | + Name string | |
| 66 | + Description string | |
| 67 | + Visibility string | |
| 68 | + PrimaryLanguage string | |
| 69 | + LicenseKey string | |
| 70 | + StarCount int64 | |
| 71 | + ForkCount int64 | |
| 72 | + WatcherCount int64 | |
| 73 | + IsFork bool | |
| 74 | + UpdatedAt time.Time | |
| 75 | + URL string | |
| 76 | +} | |
| 77 | + | |
| 78 | +func (h globalNavHandler) RedirectIssues(w http.ResponseWriter, r *http.Request) { | |
| 79 | + http.Redirect(w, r, "/issues/assigned", http.StatusSeeOther) | |
| 80 | +} | |
| 81 | + | |
| 82 | +func (h globalNavHandler) ServeIssues(w http.ResponseWriter, r *http.Request) { | |
| 83 | + view := chi.URLParam(r, "view") | |
| 84 | + if !validIssueView(view) { | |
| 85 | + http.Redirect(w, r, "/issues/assigned", http.StatusSeeOther) | |
| 86 | + return | |
| 87 | + } | |
| 88 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 89 | + rawQuery := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 90 | + state := globalStateFromRequest(r, rawQuery) | |
| 91 | + displayQuery := rawQuery | |
| 92 | + if displayQuery == "" { | |
| 93 | + displayQuery = defaultIssueQuery(view, state) | |
| 94 | + } | |
| 95 | + searchText := globalIssueSearchText(rawQuery) | |
| 96 | + | |
| 97 | + items, counts, err := h.listIssues(r.Context(), viewer.PolicyActor(), viewer.ID, "issue", view, state, searchText) | |
| 98 | + if err != nil { | |
| 99 | + h.logError(r.Context(), "global issues list", err) | |
| 100 | + h.render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 101 | + return | |
| 102 | + } | |
| 103 | + | |
| 104 | + data := map[string]any{ | |
| 105 | + "Title": "Issues", | |
| 106 | + "Heading": issueViewLabel(view), | |
| 107 | + "Query": displayQuery, | |
| 108 | + "State": state, | |
| 109 | + "StateTabs": stateTabs(r.URL.Path, rawQuery, state, counts), | |
| 110 | + "Views": issueViewTabs(view, rawQuery, state), | |
| 111 | + "Issues": items, | |
| 112 | + "Counts": counts, | |
| 113 | + "ResultCount": countForState(counts, state), | |
| 114 | + "EmptyTitle": "No issues matched this view", | |
| 115 | + "EmptyMessage": "Try another filter or open a repository to create a new issue.", | |
| 116 | + "NewHref": "/issues/new", | |
| 117 | + } | |
| 118 | + if err := h.render.RenderPage(w, r, "dashboard/issues", data); err != nil { | |
| 119 | + h.logError(r.Context(), "render global issues", err) | |
| 120 | + http.Error(w, "internal server error", http.StatusInternalServerError) | |
| 121 | + } | |
| 122 | +} | |
| 123 | + | |
| 124 | +func (h globalNavHandler) ServeNewIssue(w http.ResponseWriter, r *http.Request) { | |
| 125 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 126 | + searchText := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 127 | + repos, total, err := h.listRepos(r.Context(), viewer.PolicyActor(), viewer.ID, "contributions", searchText, true) | |
| 128 | + if err != nil { | |
| 129 | + h.logError(r.Context(), "global new issue repos", err) | |
| 130 | + h.render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 131 | + return | |
| 132 | + } | |
| 133 | + data := map[string]any{ | |
| 134 | + "Title": "New issue", | |
| 135 | + "Heading": "New issue", | |
| 136 | + "Query": searchText, | |
| 137 | + "Repos": repos, | |
| 138 | + "TotalCount": total, | |
| 139 | + "EmptyTitle": "No repositories with issues enabled", | |
| 140 | + "EmptyBody": "Create a repository or choose one with issues enabled before opening an issue.", | |
| 141 | + "ChooseIssue": true, | |
| 142 | + } | |
| 143 | + if err := h.render.RenderPage(w, r, "dashboard/new_issue", data); err != nil { | |
| 144 | + h.logError(r.Context(), "render global new issue", err) | |
| 145 | + http.Error(w, "internal server error", http.StatusInternalServerError) | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +func (h globalNavHandler) ServePulls(w http.ResponseWriter, r *http.Request) { | |
| 150 | + view := normalizePullView(r.URL.Query().Get("view")) | |
| 151 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 152 | + rawQuery := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 153 | + state := globalStateFromRequest(r, rawQuery) | |
| 154 | + displayQuery := rawQuery | |
| 155 | + if displayQuery == "" { | |
| 156 | + displayQuery = defaultPullQuery(view, state, viewer.Username) | |
| 157 | + } | |
| 158 | + searchText := globalIssueSearchText(rawQuery) | |
| 159 | + | |
| 160 | + items, counts, err := h.listIssues(r.Context(), viewer.PolicyActor(), viewer.ID, "pr", view, state, searchText) | |
| 161 | + if err != nil { | |
| 162 | + h.logError(r.Context(), "global pulls list", err) | |
| 163 | + h.render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 164 | + return | |
| 165 | + } | |
| 166 | + | |
| 167 | + data := map[string]any{ | |
| 168 | + "Title": "Pull requests", | |
| 169 | + "Heading": "Pull requests", | |
| 170 | + "Query": displayQuery, | |
| 171 | + "State": state, | |
| 172 | + "StateTabs": stateTabs("/pulls", rawQuery, state, counts), | |
| 173 | + "Views": pullViewTabs(view, rawQuery, state), | |
| 174 | + "Pulls": items, | |
| 175 | + "Counts": counts, | |
| 176 | + "ResultCount": countForState(counts, state), | |
| 177 | + "EmptyTitle": "No pull requests matched this view", | |
| 178 | + } | |
| 179 | + if err := h.render.RenderPage(w, r, "dashboard/pulls", data); err != nil { | |
| 180 | + h.logError(r.Context(), "render global pulls", err) | |
| 181 | + http.Error(w, "internal server error", http.StatusInternalServerError) | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +func (h globalNavHandler) ServeRepos(w http.ResponseWriter, r *http.Request) { | |
| 186 | + view := normalizeRepoView(r.URL.Query().Get("view")) | |
| 187 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 188 | + searchText := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 189 | + repos, total, err := h.listRepos(r.Context(), viewer.PolicyActor(), viewer.ID, view, searchText, false) | |
| 190 | + if err != nil { | |
| 191 | + h.logError(r.Context(), "global repos list", err) | |
| 192 | + h.render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 193 | + return | |
| 194 | + } | |
| 195 | + | |
| 196 | + data := map[string]any{ | |
| 197 | + "Title": "Repositories", | |
| 198 | + "Heading": repoViewLabel(view), | |
| 199 | + "Query": searchText, | |
| 200 | + "Views": repoViewTabs(view, searchText), | |
| 201 | + "Repos": repos, | |
| 202 | + "TotalCount": total, | |
| 203 | + "EmptyTitle": "No repositories matched this view", | |
| 204 | + "EmptyBody": "Create a repository or adjust your filters.", | |
| 205 | + "NewRepoHref": "/new", | |
| 206 | + } | |
| 207 | + if err := h.render.RenderPage(w, r, "dashboard/repos", data); err != nil { | |
| 208 | + h.logError(r.Context(), "render global repos", err) | |
| 209 | + http.Error(w, "internal server error", http.StatusInternalServerError) | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +func (h globalNavHandler) listIssues(ctx context.Context, actor policy.Actor, viewerID int64, kind, view, state, searchText string) ([]globalIssueRow, globalIssueCounts, error) { | |
| 214 | + if h.pool == nil { | |
| 215 | + return nil, globalIssueCounts{}, fmt.Errorf("database unavailable") | |
| 216 | + } | |
| 217 | + | |
| 218 | + countWhere, countArgs := buildGlobalIssueWhere(actor, viewerID, kind, view, "", searchText) | |
| 219 | + countSQL := fmt.Sprintf(` | |
| 220 | +SELECT | |
| 221 | + COUNT(*)::bigint, | |
| 222 | + COUNT(*) FILTER (WHERE i.state = 'open')::bigint, | |
| 223 | + COUNT(*) FILTER (WHERE i.state = 'closed')::bigint | |
| 224 | +FROM issues i | |
| 225 | +JOIN repos r ON r.id = i.repo_id | |
| 226 | +LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id | |
| 227 | +LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id | |
| 228 | +WHERE %s`, countWhere) | |
| 229 | + var counts globalIssueCounts | |
| 230 | + if err := h.pool.QueryRow(ctx, countSQL, countArgs...).Scan(&counts.Total, &counts.Open, &counts.Closed); err != nil { | |
| 231 | + return nil, counts, err | |
| 232 | + } | |
| 233 | + | |
| 234 | + where, args := buildGlobalIssueWhere(actor, viewerID, kind, view, state, searchText) | |
| 235 | + limitPlaceholder := nextPlaceholder(&args, globalDashboardLimit) | |
| 236 | + ownerExpr := globalRepoOwnerExpr() | |
| 237 | + listSQL := fmt.Sprintf(` | |
| 238 | +SELECT | |
| 239 | + i.id, | |
| 240 | + %s AS owner, | |
| 241 | + r.name::text AS repo_name, | |
| 242 | + i.number, | |
| 243 | + i.title, | |
| 244 | + i.state::text AS state, | |
| 245 | + i.kind::text AS kind, | |
| 246 | + COALESCE(author.username::text, '') AS author_name, | |
| 247 | + i.updated_at, | |
| 248 | + (SELECT COUNT(*)::bigint FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count | |
| 249 | +FROM issues i | |
| 250 | +JOIN repos r ON r.id = i.repo_id | |
| 251 | +LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id | |
| 252 | +LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id | |
| 253 | +LEFT JOIN users author ON author.id = i.author_user_id | |
| 254 | +WHERE %s | |
| 255 | +ORDER BY i.updated_at DESC, i.id DESC | |
| 256 | +LIMIT %s`, ownerExpr, where, limitPlaceholder) | |
| 257 | + | |
| 258 | + rows, err := h.pool.Query(ctx, listSQL, args...) | |
| 259 | + if err != nil { | |
| 260 | + return nil, counts, err | |
| 261 | + } | |
| 262 | + defer rows.Close() | |
| 263 | + | |
| 264 | + var out []globalIssueRow | |
| 265 | + for rows.Next() { | |
| 266 | + var row globalIssueRow | |
| 267 | + if err := rows.Scan( | |
| 268 | + &row.ID, | |
| 269 | + &row.Owner, | |
| 270 | + &row.RepoName, | |
| 271 | + &row.Number, | |
| 272 | + &row.Title, | |
| 273 | + &row.State, | |
| 274 | + &row.Kind, | |
| 275 | + &row.AuthorName, | |
| 276 | + &row.UpdatedAt, | |
| 277 | + &row.CommentCount, | |
| 278 | + ); err != nil { | |
| 279 | + return nil, counts, err | |
| 280 | + } | |
| 281 | + row.RepoURL = "/" + row.Owner + "/" + row.RepoName | |
| 282 | + threadPath := "issues" | |
| 283 | + if row.Kind == "pr" { | |
| 284 | + threadPath = "pulls" | |
| 285 | + } | |
| 286 | + row.URL = row.RepoURL + "/" + threadPath + "/" + strconv.FormatInt(row.Number, 10) | |
| 287 | + out = append(out, row) | |
| 288 | + } | |
| 289 | + if err := rows.Err(); err != nil { | |
| 290 | + return nil, counts, err | |
| 291 | + } | |
| 292 | + return out, counts, nil | |
| 293 | +} | |
| 294 | + | |
| 295 | +func (h globalNavHandler) listRepos(ctx context.Context, actor policy.Actor, viewerID int64, view, searchText string, requireIssues bool) ([]globalRepoRow, int64, error) { | |
| 296 | + if h.pool == nil { | |
| 297 | + return nil, 0, fmt.Errorf("database unavailable") | |
| 298 | + } | |
| 299 | + where, args := buildGlobalRepoWhere(actor, viewerID, view, searchText, requireIssues) | |
| 300 | + countSQL := fmt.Sprintf(` | |
| 301 | +SELECT COUNT(*)::bigint | |
| 302 | +FROM repos r | |
| 303 | +LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id | |
| 304 | +LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id | |
| 305 | +WHERE %s`, where) | |
| 306 | + var total int64 | |
| 307 | + if err := h.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { | |
| 308 | + return nil, 0, err | |
| 309 | + } | |
| 310 | + | |
| 311 | + listWhere, listArgs := buildGlobalRepoWhere(actor, viewerID, view, searchText, requireIssues) | |
| 312 | + limitPlaceholder := nextPlaceholder(&listArgs, globalDashboardLimit) | |
| 313 | + ownerExpr := globalRepoOwnerExpr() | |
| 314 | + listSQL := fmt.Sprintf(` | |
| 315 | +SELECT | |
| 316 | + r.id, | |
| 317 | + %s AS owner, | |
| 318 | + r.name::text AS name, | |
| 319 | + r.description, | |
| 320 | + r.visibility::text AS visibility, | |
| 321 | + COALESCE(r.primary_language, '') AS primary_language, | |
| 322 | + COALESCE(r.license_key, '') AS license_key, | |
| 323 | + r.star_count, | |
| 324 | + r.fork_count, | |
| 325 | + r.watcher_count, | |
| 326 | + (r.fork_of_repo_id IS NOT NULL) AS is_fork, | |
| 327 | + r.updated_at | |
| 328 | +FROM repos r | |
| 329 | +LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id | |
| 330 | +LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id | |
| 331 | +WHERE %s | |
| 332 | +ORDER BY r.updated_at DESC, r.id DESC | |
| 333 | +LIMIT %s`, ownerExpr, listWhere, limitPlaceholder) | |
| 334 | + | |
| 335 | + rows, err := h.pool.Query(ctx, listSQL, listArgs...) | |
| 336 | + if err != nil { | |
| 337 | + return nil, total, err | |
| 338 | + } | |
| 339 | + defer rows.Close() | |
| 340 | + | |
| 341 | + var out []globalRepoRow | |
| 342 | + for rows.Next() { | |
| 343 | + var row globalRepoRow | |
| 344 | + if err := rows.Scan( | |
| 345 | + &row.ID, | |
| 346 | + &row.Owner, | |
| 347 | + &row.Name, | |
| 348 | + &row.Description, | |
| 349 | + &row.Visibility, | |
| 350 | + &row.PrimaryLanguage, | |
| 351 | + &row.LicenseKey, | |
| 352 | + &row.StarCount, | |
| 353 | + &row.ForkCount, | |
| 354 | + &row.WatcherCount, | |
| 355 | + &row.IsFork, | |
| 356 | + &row.UpdatedAt, | |
| 357 | + ); err != nil { | |
| 358 | + return nil, total, err | |
| 359 | + } | |
| 360 | + row.URL = "/" + row.Owner + "/" + row.Name | |
| 361 | + out = append(out, row) | |
| 362 | + } | |
| 363 | + if err := rows.Err(); err != nil { | |
| 364 | + return nil, total, err | |
| 365 | + } | |
| 366 | + return out, total, nil | |
| 367 | +} | |
| 368 | + | |
| 369 | +func buildGlobalIssueWhere(actor policy.Actor, viewerID int64, kind, view, state, searchText string) (string, []any) { | |
| 370 | + visClause, visArgs := policy.VisibilityPredicate(actor, "r", 1) | |
| 371 | + args := append([]any{}, visArgs...) | |
| 372 | + clauses := []string{ | |
| 373 | + visClause, | |
| 374 | + "((" + "r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL" + ") OR (" + "r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL" + "))", | |
| 375 | + } | |
| 376 | + | |
| 377 | + kindPlaceholder := nextPlaceholder(&args, kind) | |
| 378 | + clauses = append(clauses, "i.kind = "+kindPlaceholder+"::issue_kind") | |
| 379 | + if state != "" && state != "all" { | |
| 380 | + clauses = append(clauses, "i.state = "+nextPlaceholder(&args, state)+"::issue_state") | |
| 381 | + } | |
| 382 | + if searchText != "" { | |
| 383 | + pattern := "%" + strings.ToLower(searchText) + "%" | |
| 384 | + p := nextPlaceholder(&args, pattern) | |
| 385 | + ownerExpr := "LOWER(" + globalRepoOwnerExpr() + ")" | |
| 386 | + clauses = append(clauses, "(LOWER(i.title) LIKE "+p+" OR LOWER(i.body) LIKE "+p+" OR LOWER(r.name::text) LIKE "+p+" OR "+ownerExpr+" LIKE "+p+")") | |
| 387 | + } | |
| 388 | + | |
| 389 | + uid := nextPlaceholder(&args, viewerID) | |
| 390 | + threadKind := func() string { | |
| 391 | + return nextPlaceholder(&args, kind) + "::notification_thread_kind" | |
| 392 | + } | |
| 393 | + switch view { | |
| 394 | + case "assigned": | |
| 395 | + clauses = append(clauses, "EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = "+uid+")") | |
| 396 | + case "created": | |
| 397 | + clauses = append(clauses, "i.author_user_id = "+uid) | |
| 398 | + case "mentioned": | |
| 399 | + tk := threadKind() | |
| 400 | + clauses = append(clauses, "(EXISTS (SELECT 1 FROM notifications n WHERE n.recipient_user_id = "+uid+" AND n.thread_kind = "+tk+" AND n.thread_id = i.id AND n.reason = 'mention') OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.reason = 'mention'))") | |
| 401 | + case "recent": | |
| 402 | + tk := threadKind() | |
| 403 | + clauses = append(clauses, "(i.author_user_id = "+uid+" OR EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = "+uid+") OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.subscribed = true))") | |
| 404 | + case "review-requests": | |
| 405 | + tk := threadKind() | |
| 406 | + clauses = append(clauses, "(EXISTS (SELECT 1 FROM notifications n WHERE n.recipient_user_id = "+uid+" AND n.thread_kind = "+tk+" AND n.thread_id = i.id AND n.reason = 'review_requested') OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.reason = 'review_requested'))") | |
| 407 | + default: | |
| 408 | + clauses = append(clauses, "i.author_user_id = "+uid) | |
| 409 | + } | |
| 410 | + return strings.Join(clauses, " AND "), args | |
| 411 | +} | |
| 412 | + | |
| 413 | +func buildGlobalRepoWhere(actor policy.Actor, viewerID int64, view, searchText string, requireIssues bool) (string, []any) { | |
| 414 | + visClause, visArgs := policy.VisibilityPredicate(actor, "r", 1) | |
| 415 | + args := append([]any{}, visArgs...) | |
| 416 | + clauses := []string{ | |
| 417 | + visClause, | |
| 418 | + "((r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL) OR (r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL))", | |
| 419 | + } | |
| 420 | + if requireIssues { | |
| 421 | + clauses = append(clauses, "r.has_issues = true") | |
| 422 | + } | |
| 423 | + if searchText != "" { | |
| 424 | + pattern := "%" + strings.ToLower(searchText) + "%" | |
| 425 | + p := nextPlaceholder(&args, pattern) | |
| 426 | + ownerExpr := "LOWER(" + globalRepoOwnerExpr() + ")" | |
| 427 | + clauses = append(clauses, "(LOWER(r.name::text) LIKE "+p+" OR LOWER(r.description) LIKE "+p+" OR "+ownerExpr+" LIKE "+p+")") | |
| 428 | + } | |
| 429 | + uid := nextPlaceholder(&args, viewerID) | |
| 430 | + contributionClause := "(r.owner_user_id = " + uid + " OR r.owner_org_id IN (SELECT org_id FROM org_members WHERE user_id = " + uid + ") OR EXISTS (SELECT 1 FROM repo_collaborators rc WHERE rc.repo_id = r.id AND rc.user_id = " + uid + "))" | |
| 431 | + switch view { | |
| 432 | + case "mine": | |
| 433 | + clauses = append(clauses, "r.owner_user_id = "+uid) | |
| 434 | + case "forks": | |
| 435 | + clauses = append(clauses, "r.fork_of_repo_id IS NOT NULL", contributionClause) | |
| 436 | + case "admin": | |
| 437 | + clauses = append(clauses, "(r.owner_user_id = "+uid+" OR r.owner_org_id IN (SELECT org_id FROM org_members WHERE user_id = "+uid+" AND role = 'owner') OR EXISTS (SELECT 1 FROM repo_collaborators rc WHERE rc.repo_id = r.id AND rc.user_id = "+uid+" AND rc.role = 'admin'))") | |
| 438 | + default: | |
| 439 | + clauses = append(clauses, contributionClause) | |
| 440 | + } | |
| 441 | + return strings.Join(clauses, " AND "), args | |
| 442 | +} | |
| 443 | + | |
| 444 | +func nextPlaceholder(args *[]any, v any) string { | |
| 445 | + *args = append(*args, v) | |
| 446 | + return "$" + strconv.Itoa(len(*args)) | |
| 447 | +} | |
| 448 | + | |
| 449 | +func globalRepoOwnerExpr() string { | |
| 450 | + return "COALESCE(owner_user.username::text, owner_org.slug::text, '')" | |
| 451 | +} | |
| 452 | + | |
| 453 | +func validIssueView(view string) bool { | |
| 454 | + switch view { | |
| 455 | + case "assigned", "created", "mentioned", "recent": | |
| 456 | + return true | |
| 457 | + default: | |
| 458 | + return false | |
| 459 | + } | |
| 460 | +} | |
| 461 | + | |
| 462 | +func normalizePullView(view string) string { | |
| 463 | + switch view { | |
| 464 | + case "assigned", "mentioned", "review-requests": | |
| 465 | + return view | |
| 466 | + default: | |
| 467 | + return "created" | |
| 468 | + } | |
| 469 | +} | |
| 470 | + | |
| 471 | +func normalizeRepoView(view string) string { | |
| 472 | + switch view { | |
| 473 | + case "mine", "forks", "admin": | |
| 474 | + return view | |
| 475 | + default: | |
| 476 | + return "contributions" | |
| 477 | + } | |
| 478 | +} | |
| 479 | + | |
| 480 | +func globalStateFromRequest(r *http.Request, rawQuery string) string { | |
| 481 | + state := normalizeGlobalState(r.URL.Query().Get("state")) | |
| 482 | + lower := strings.ToLower(rawQuery) | |
| 483 | + switch { | |
| 484 | + case strings.Contains(lower, "state:closed"), strings.Contains(lower, "is:closed"): | |
| 485 | + return "closed" | |
| 486 | + case strings.Contains(lower, "state:open"), strings.Contains(lower, "is:open"): | |
| 487 | + return "open" | |
| 488 | + default: | |
| 489 | + return state | |
| 490 | + } | |
| 491 | +} | |
| 492 | + | |
| 493 | +func normalizeGlobalState(raw string) string { | |
| 494 | + switch raw { | |
| 495 | + case "closed", "all": | |
| 496 | + return raw | |
| 497 | + default: | |
| 498 | + return "open" | |
| 499 | + } | |
| 500 | +} | |
| 501 | + | |
| 502 | +func globalIssueSearchText(raw string) string { | |
| 503 | + if raw == "" { | |
| 504 | + return "" | |
| 505 | + } | |
| 506 | + parts := strings.Fields(raw) | |
| 507 | + keep := make([]string, 0, len(parts)) | |
| 508 | + for _, part := range parts { | |
| 509 | + token := strings.ToLower(strings.TrimSpace(part)) | |
| 510 | + switch { | |
| 511 | + case token == "": | |
| 512 | + continue | |
| 513 | + case strings.HasPrefix(token, "is:"), | |
| 514 | + strings.HasPrefix(token, "state:"), | |
| 515 | + strings.HasPrefix(token, "archived:"), | |
| 516 | + strings.HasPrefix(token, "assignee:"), | |
| 517 | + strings.HasPrefix(token, "author:"), | |
| 518 | + strings.HasPrefix(token, "mentions:"), | |
| 519 | + strings.HasPrefix(token, "involves:"), | |
| 520 | + strings.HasPrefix(token, "review-requested:"), | |
| 521 | + strings.HasPrefix(token, "sort:"): | |
| 522 | + continue | |
| 523 | + default: | |
| 524 | + keep = append(keep, part) | |
| 525 | + } | |
| 526 | + } | |
| 527 | + return strings.Join(keep, " ") | |
| 528 | +} | |
| 529 | + | |
| 530 | +func globalQueryWithoutStateOperators(raw string) string { | |
| 531 | + if raw == "" { | |
| 532 | + return "" | |
| 533 | + } | |
| 534 | + parts := strings.Fields(raw) | |
| 535 | + keep := make([]string, 0, len(parts)) | |
| 536 | + for _, part := range parts { | |
| 537 | + token := strings.ToLower(strings.TrimSpace(part)) | |
| 538 | + switch { | |
| 539 | + case token == "": | |
| 540 | + continue | |
| 541 | + case strings.HasPrefix(token, "state:"), | |
| 542 | + token == "is:open", | |
| 543 | + token == "is:closed": | |
| 544 | + continue | |
| 545 | + default: | |
| 546 | + keep = append(keep, part) | |
| 547 | + } | |
| 548 | + } | |
| 549 | + return strings.Join(keep, " ") | |
| 550 | +} | |
| 551 | + | |
| 552 | +func issueViewLabel(view string) string { | |
| 553 | + switch view { | |
| 554 | + case "created": | |
| 555 | + return "Created by me" | |
| 556 | + case "mentioned": | |
| 557 | + return "Mentioned" | |
| 558 | + case "recent": | |
| 559 | + return "Recent activity" | |
| 560 | + default: | |
| 561 | + return "Assigned to me" | |
| 562 | + } | |
| 563 | +} | |
| 564 | + | |
| 565 | +func repoViewLabel(view string) string { | |
| 566 | + switch view { | |
| 567 | + case "mine": | |
| 568 | + return "My repositories" | |
| 569 | + case "forks": | |
| 570 | + return "My forks" | |
| 571 | + case "admin": | |
| 572 | + return "Admin access" | |
| 573 | + default: | |
| 574 | + return "My contributions" | |
| 575 | + } | |
| 576 | +} | |
| 577 | + | |
| 578 | +func defaultIssueQuery(view, state string) string { | |
| 579 | + switch view { | |
| 580 | + case "created": | |
| 581 | + return "is:issue state:" + state + " archived:false author:@me sort:updated-desc" | |
| 582 | + case "mentioned": | |
| 583 | + return "is:issue state:" + state + " archived:false mentions:@me sort:updated-desc" | |
| 584 | + case "recent": | |
| 585 | + return "is:issue state:" + state + " archived:false involves:@me sort:updated-desc" | |
| 586 | + default: | |
| 587 | + return "is:issue state:" + state + " archived:false assignee:@me sort:updated-desc" | |
| 588 | + } | |
| 589 | +} | |
| 590 | + | |
| 591 | +func defaultPullQuery(view, state, username string) string { | |
| 592 | + if username == "" { | |
| 593 | + username = "@me" | |
| 594 | + } else { | |
| 595 | + username = "@" + username | |
| 596 | + } | |
| 597 | + switch view { | |
| 598 | + case "assigned": | |
| 599 | + return "is:pr state:" + state + " archived:false assignee:@me sort:updated-desc" | |
| 600 | + case "mentioned": | |
| 601 | + return "is:pr state:" + state + " archived:false mentions:@me sort:updated-desc" | |
| 602 | + case "review-requests": | |
| 603 | + return "is:pr state:" + state + " archived:false review-requested:@me sort:updated-desc" | |
| 604 | + default: | |
| 605 | + return "is:pr state:" + state + " archived:false author:" + username | |
| 606 | + } | |
| 607 | +} | |
| 608 | + | |
| 609 | +func issueViewTabs(active, query, state string) []globalNavTab { | |
| 610 | + return []globalNavTab{ | |
| 611 | + {Key: "assigned", Label: "Assigned to me", Icon: "people", Href: dashboardHref("/issues/assigned", queryValues(query, state, "")), Selected: active == "assigned"}, | |
| 612 | + {Key: "created", Label: "Created by me", Icon: "smiley", Href: dashboardHref("/issues/created", queryValues(query, state, "")), Selected: active == "created"}, | |
| 613 | + {Key: "mentioned", Label: "Mentioned", Icon: "mention", Href: dashboardHref("/issues/mentioned", queryValues(query, state, "")), Selected: active == "mentioned"}, | |
| 614 | + {Key: "recent", Label: "Recent activity", Icon: "history", Href: dashboardHref("/issues/recent", queryValues(query, state, "")), Selected: active == "recent"}, | |
| 615 | + } | |
| 616 | +} | |
| 617 | + | |
| 618 | +func pullViewTabs(active, query, state string) []globalNavTab { | |
| 619 | + return []globalNavTab{ | |
| 620 | + {Key: "created", Label: "Created", Href: dashboardHref("/pulls", queryValues(query, state, "created")), Selected: active == "created"}, | |
| 621 | + {Key: "assigned", Label: "Assigned", Href: dashboardHref("/pulls", queryValues(query, state, "assigned")), Selected: active == "assigned"}, | |
| 622 | + {Key: "mentioned", Label: "Mentioned", Href: dashboardHref("/pulls", queryValues(query, state, "mentioned")), Selected: active == "mentioned"}, | |
| 623 | + {Key: "review-requests", Label: "Review requests", Href: dashboardHref("/pulls", queryValues(query, state, "review-requests")), Selected: active == "review-requests"}, | |
| 624 | + } | |
| 625 | +} | |
| 626 | + | |
| 627 | +func repoViewTabs(active, query string) []globalNavTab { | |
| 628 | + return []globalNavTab{ | |
| 629 | + {Key: "contributions", Label: "My contributions", Icon: "people", Href: dashboardHref("/repos", repoQueryValues(query, "contributions")), Selected: active == "contributions"}, | |
| 630 | + {Key: "mine", Label: "My repositories", Icon: "repo", Href: dashboardHref("/repos", repoQueryValues(query, "mine")), Selected: active == "mine"}, | |
| 631 | + {Key: "forks", Label: "My forks", Icon: "repo-forked", Href: dashboardHref("/repos", repoQueryValues(query, "forks")), Selected: active == "forks"}, | |
| 632 | + {Key: "admin", Label: "Admin access", Icon: "gear", Href: dashboardHref("/repos", repoQueryValues(query, "admin")), Selected: active == "admin"}, | |
| 633 | + } | |
| 634 | +} | |
| 635 | + | |
| 636 | +func stateTabs(path, query, active string, counts globalIssueCounts) []globalNavTab { | |
| 637 | + query = globalQueryWithoutStateOperators(query) | |
| 638 | + return []globalNavTab{ | |
| 639 | + {Key: "open", Label: "Open", Icon: "issue-opened", Count: counts.Open, Href: dashboardHref(path, queryValues(query, "open", "")), Selected: active == "open"}, | |
| 640 | + {Key: "closed", Label: "Closed", Icon: "check", Count: counts.Closed, Href: dashboardHref(path, queryValues(query, "closed", "")), Selected: active == "closed"}, | |
| 641 | + {Key: "all", Label: "All", Icon: "list-unordered", Count: counts.Total, Href: dashboardHref(path, queryValues(query, "all", "")), Selected: active == "all"}, | |
| 642 | + } | |
| 643 | +} | |
| 644 | + | |
| 645 | +func queryValues(query, state, view string) url.Values { | |
| 646 | + values := url.Values{} | |
| 647 | + if query != "" { | |
| 648 | + values.Set("q", query) | |
| 649 | + } | |
| 650 | + if state != "" && state != "open" { | |
| 651 | + values.Set("state", state) | |
| 652 | + } | |
| 653 | + if view != "" && view != "created" { | |
| 654 | + values.Set("view", view) | |
| 655 | + } | |
| 656 | + return values | |
| 657 | +} | |
| 658 | + | |
| 659 | +func repoQueryValues(query, view string) url.Values { | |
| 660 | + values := url.Values{} | |
| 661 | + if query != "" { | |
| 662 | + values.Set("q", query) | |
| 663 | + } | |
| 664 | + if view != "" && view != "contributions" { | |
| 665 | + values.Set("view", view) | |
| 666 | + } | |
| 667 | + return values | |
| 668 | +} | |
| 669 | + | |
| 670 | +func dashboardHref(path string, values url.Values) string { | |
| 671 | + if encoded := values.Encode(); encoded != "" { | |
| 672 | + return path + "?" + encoded | |
| 673 | + } | |
| 674 | + return path | |
| 675 | +} | |
| 676 | + | |
| 677 | +func countForState(counts globalIssueCounts, state string) int64 { | |
| 678 | + switch state { | |
| 679 | + case "closed": | |
| 680 | + return counts.Closed | |
| 681 | + case "all": | |
| 682 | + return counts.Total | |
| 683 | + default: | |
| 684 | + return counts.Open | |
| 685 | + } | |
| 686 | +} | |
| 687 | + | |
| 688 | +func (h globalNavHandler) logError(ctx context.Context, msg string, err error) { | |
| 689 | + if h.logger != nil { | |
| 690 | + h.logger.ErrorContext(ctx, msg, "error", err) | |
| 691 | + } | |
| 692 | +} | |
internal/web/handlers/global_dashboard_test.goadded@@ -0,0 +1,58 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package handlers | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "net/http/httptest" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func TestGlobalIssueSearchTextDropsDashboardOperators(t *testing.T) { | |
| 11 | + t.Parallel() | |
| 12 | + | |
| 13 | + got := globalIssueSearchText("is:issue state:open archived:false assignee:@me sort:updated-desc flaky deploy") | |
| 14 | + if got != "flaky deploy" { | |
| 15 | + t.Fatalf("globalIssueSearchText = %q, want %q", got, "flaky deploy") | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +func TestStateTabsDropStaleQueryStateOperators(t *testing.T) { | |
| 20 | + t.Parallel() | |
| 21 | + | |
| 22 | + tabs := stateTabs("/issues/assigned", "is:issue state:closed archived:false bug", "closed", globalIssueCounts{ | |
| 23 | + Total: 3, | |
| 24 | + Open: 1, | |
| 25 | + Closed: 2, | |
| 26 | + }) | |
| 27 | + if got := tabs[0].Href; got != "/issues/assigned?q=is%3Aissue+archived%3Afalse+bug" { | |
| 28 | + t.Fatalf("open state href = %q", got) | |
| 29 | + } | |
| 30 | + if got := tabs[1].Href; got != "/issues/assigned?q=is%3Aissue+archived%3Afalse+bug&state=closed" { | |
| 31 | + t.Fatalf("closed state href = %q", got) | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +func TestGlobalStateFromRequestHonorsQueryOperators(t *testing.T) { | |
| 36 | + t.Parallel() | |
| 37 | + | |
| 38 | + req := httptest.NewRequest("GET", "/issues/assigned?state=open", nil) | |
| 39 | + if got := globalStateFromRequest(req, "is:issue state:closed archived:false"); got != "closed" { | |
| 40 | + t.Fatalf("globalStateFromRequest state:closed = %q, want closed", got) | |
| 41 | + } | |
| 42 | + | |
| 43 | + req = httptest.NewRequest("GET", "/issues/assigned?state=all", nil) | |
| 44 | + if got := globalStateFromRequest(req, ""); got != "all" { | |
| 45 | + t.Fatalf("globalStateFromRequest query param = %q, want all", got) | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +func TestDashboardHrefOmitsEmptyQuery(t *testing.T) { | |
| 50 | + t.Parallel() | |
| 51 | + | |
| 52 | + if got := dashboardHref("/pulls", queryValues("", "open", "created")); got != "/pulls" { | |
| 53 | + t.Fatalf("dashboardHref default values = %q, want /pulls", got) | |
| 54 | + } | |
| 55 | + if got := dashboardHref("/pulls", queryValues("state:closed bug", "closed", "assigned")); got != "/pulls?q=state%3Aclosed+bug&state=closed&view=assigned" { | |
| 56 | + t.Fatalf("dashboardHref populated values = %q", got) | |
| 57 | + } | |
| 58 | +} | |
internal/web/handlers/handlers.gomodified@@ -258,6 +258,15 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http | ||
| 258 | 258 | r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger}.ServeHTTP) |
| 259 | 259 | r.Get("/explore", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeExplore) |
| 260 | 260 | r.Get("/trending", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeTrending) |
| 261 | + globalNavH := globalNavHandler{render: rr, logger: deps.Logger, pool: deps.Pool} | |
| 262 | + r.Group(func(r chi.Router) { | |
| 263 | + r.Use(middleware.RequireUser) | |
| 264 | + r.Get("/issues", globalNavH.RedirectIssues) | |
| 265 | + r.Get("/issues/new", globalNavH.ServeNewIssue) | |
| 266 | + r.Get("/issues/{view}", globalNavH.ServeIssues) | |
| 267 | + r.Get("/pulls", globalNavH.ServePulls) | |
| 268 | + r.Get("/repos", globalNavH.ServeRepos) | |
| 269 | + }) | |
| 261 | 270 | // /internal/panic is a dev affordance: GET it to trigger the |
| 262 | 271 | // panic-recovery path so an operator can confirm the styled 500 |
| 263 | 272 | // page renders. S35 will gate this behind a dev flag. |
internal/web/render/octicons.gomodified@@ -91,6 +91,8 @@ func BuiltinOcticons() OcticonResolver { | ||
| 91 | 91 | `><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 11H8.06l-3.31 2.48A.75.75 0 0 1 3.5 12.88V11h-.75A1.75 1.75 0 0 1 1 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v6.5c0 .138.112.25.25.25h1.5a.75.75 0 0 1 .75.75v1.13l2.36-1.77a.75.75 0 0 1 .45-.15h5.44a.25.25 0 0 0 .25-.25v-6.5a.25.25 0 0 0-.25-.25Z"/></svg>`), |
| 92 | 92 | "history": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 93 | 93 | `><path d="M1.643 3.143.427 1.927A.25.25 0 0 0 0 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 0 0 .177-.427L2.715 4.215A6.5 6.5 0 1 1 8 14.5a.75.75 0 0 0 0 1.5 8 8 0 1 0-6.357-12.857ZM7.25 4.75a.75.75 0 0 1 1.5 0v3.19l2.03 2.03a.75.75 0 1 1-1.06 1.06L7.47 8.78a.75.75 0 0 1-.22-.53Z"/></svg>`), |
| 94 | + "light-bulb": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + | |
| 95 | + `><path d="M8 1.5A4.5 4.5 0 0 0 5.5 9.739c.329.205.5.505.5.802V11a.75.75 0 0 0 1.5 0v-.459c0-.867-.514-1.61-1.204-2.04A3 3 0 1 1 9.5 8.5C8.875 8.89 8.5 9.573 8.5 10.25V11a.75.75 0 0 0 1.5 0v-.75c0-.12.072-.29.296-.429A4.5 4.5 0 0 0 8 1.5ZM6.75 13a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"/></svg>`), | |
| 94 | 96 | "calendar": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 95 | 97 | `><path d="M4.75 0a.75.75 0 0 1 .75.75V2h5V.75a.75.75 0 0 1 1.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 0 1 4.75 0ZM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V7.5Zm10.75-4H2.75a.25.25 0 0 0-.25.25V6h11V3.75a.25.25 0 0 0-.25-.25Z"/></svg>`), |
| 96 | 98 | "stopwatch": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
@@ -103,6 +105,8 @@ func BuiltinOcticons() OcticonResolver { | ||
| 103 | 105 | `><path d="M8 1.5A2.5 2.5 0 0 0 5.5 4v2h6.75c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4V4a4 4 0 0 1 7.8-1.26.75.75 0 0 1-1.42.47A2.5 2.5 0 0 0 8 1.5ZM3.75 7.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25Z"/></svg>`), |
| 104 | 106 | "tag": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 105 | 107 | `><path d="M1 2.75C1 1.784 1.784 1 2.75 1h4.586c.464 0 .909.184 1.237.513l5.914 5.914a1.75 1.75 0 0 1 0 2.475l-4.585 4.585a1.75 1.75 0 0 1-2.475 0L1.513 8.573A1.75 1.75 0 0 1 1 7.336Zm1.75-.25a.25.25 0 0 0-.25.25v4.586c0 .066.026.13.073.177l5.914 5.914a.25.25 0 0 0 .353 0l4.587-4.587a.25.25 0 0 0 0-.353L7.513 2.573a.25.25 0 0 0-.177-.073Zm2.25 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"/></svg>`), |
| 108 | + "mention": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + | |
| 109 | + `><path d="M8 1.5a6.5 6.5 0 1 0 4.596 11.096.75.75 0 0 1 1.06 1.061A8 8 0 1 1 16 8v1.5A2.5 2.5 0 0 1 11.75 11.286 4 4 0 1 1 12 9.965V9.5A1.5 1.5 0 0 0 13.5 8 5.5 5.5 0 0 0 8 1.5Zm0 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"/></svg>`), | |
| 106 | 110 | "person": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 107 | 111 | `><path d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm1.5 0a4 4 0 1 0-8 0 4 4 0 0 0 8 0ZM2 14.25C2 11.35 4.686 9 8 9s6 2.35 6 5.25a.75.75 0 0 1-1.5 0c0-2.02-2.01-3.75-4.5-3.75s-4.5 1.73-4.5 3.75a.75.75 0 0 1-1.5 0Z"/></svg>`), |
| 108 | 112 | "people": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
internal/web/static/css/shithub.cssmodified@@ -196,10 +196,76 @@ code { | ||
| 196 | 196 | align-items: center; |
| 197 | 197 | flex-shrink: 0; |
| 198 | 198 | } |
| 199 | -.shithub-nav-new { | |
| 200 | - display: inline-flex; | |
| 199 | +.shithub-nav-icon { | |
| 200 | + color: var(--fg-muted); | |
| 201 | +} | |
| 202 | +.shithub-nav-icon:hover { | |
| 203 | + color: var(--fg-default); | |
| 204 | +} | |
| 205 | +.shithub-nav-actions-divider { | |
| 206 | + width: 1px; | |
| 207 | + height: 24px; | |
| 208 | + background: var(--border-default); | |
| 209 | +} | |
| 210 | +.shithub-nav-action-menu { | |
| 211 | + position: relative; | |
| 212 | +} | |
| 213 | +.shithub-nav-action-menu > summary { | |
| 214 | + list-style: none; | |
| 215 | +} | |
| 216 | +.shithub-nav-action-menu > summary::-webkit-details-marker { | |
| 217 | + display: none; | |
| 218 | +} | |
| 219 | +.shithub-nav-create { | |
| 220 | + min-width: 48px; | |
| 221 | + height: 32px; | |
| 222 | + padding: 0 0.5rem; | |
| 223 | + gap: 0.1rem; | |
| 224 | +} | |
| 225 | +.shithub-nav-action-panel { | |
| 226 | + position: absolute; | |
| 227 | + right: 0; | |
| 228 | + top: calc(100% + 0.45rem); | |
| 229 | + z-index: 60; | |
| 230 | + display: grid; | |
| 231 | + min-width: 220px; | |
| 232 | + padding: 0.4rem; | |
| 233 | + border: 1px solid var(--border-default); | |
| 234 | + border-radius: 8px; | |
| 235 | + background: var(--canvas-default); | |
| 236 | + box-shadow: 0 12px 28px rgba(1, 4, 9, 0.36); | |
| 237 | +} | |
| 238 | +.shithub-nav-action-item { | |
| 239 | + display: grid; | |
| 240 | + grid-template-columns: 16px minmax(0, 1fr); | |
| 241 | + gap: 0.65rem; | |
| 201 | 242 | align-items: center; |
| 202 | - gap: 0.35rem; | |
| 243 | + min-height: 34px; | |
| 244 | + padding: 0.4rem 0.5rem; | |
| 245 | + border-radius: 6px; | |
| 246 | + color: var(--fg-default); | |
| 247 | + font-size: 0.875rem; | |
| 248 | + font-weight: 500; | |
| 249 | + text-decoration: none; | |
| 250 | +} | |
| 251 | +.shithub-nav-action-item svg { | |
| 252 | + color: var(--fg-muted); | |
| 253 | +} | |
| 254 | +.shithub-nav-action-item:hover { | |
| 255 | + background: var(--canvas-subtle); | |
| 256 | + text-decoration: none; | |
| 257 | +} | |
| 258 | +.shithub-nav-action-item.is-disabled { | |
| 259 | + color: var(--fg-muted); | |
| 260 | + cursor: default; | |
| 261 | +} | |
| 262 | +.shithub-nav-action-item.is-disabled:hover { | |
| 263 | + background: transparent; | |
| 264 | +} | |
| 265 | +.shithub-nav-action-divider { | |
| 266 | + height: 1px; | |
| 267 | + margin: 0.35rem -0.4rem; | |
| 268 | + background: var(--border-default); | |
| 203 | 269 | } |
| 204 | 270 | .shithub-nav-local { |
| 205 | 271 | padding: 0 1rem; |
@@ -8971,6 +9037,451 @@ button.shithub-repo-action { | ||
| 8971 | 9037 | .shithub-imp-write { background: #ffd33d; color: #1a1f24; padding: 0 0.4em; border-radius: 4px; font-weight: 600; } |
| 8972 | 9038 | .shithub-imp-read { background: rgba(255, 255, 255, 0.2); padding: 0 0.4em; border-radius: 4px; } |
| 8973 | 9039 | |
| 9040 | +/* Global dashboard surfaces: /issues, /pulls, /repos. */ | |
| 9041 | +.shithub-global-page { | |
| 9042 | + width: 100%; | |
| 9043 | + padding: 1.5rem 1rem 3rem; | |
| 9044 | +} | |
| 9045 | +.shithub-global-shell { | |
| 9046 | + display: grid; | |
| 9047 | + grid-template-columns: 240px minmax(0, 1fr); | |
| 9048 | + gap: 2rem; | |
| 9049 | + max-width: 1280px; | |
| 9050 | + margin: 0 auto; | |
| 9051 | +} | |
| 9052 | +.shithub-global-shell-wide { | |
| 9053 | + max-width: 1440px; | |
| 9054 | +} | |
| 9055 | +.shithub-global-centered { | |
| 9056 | + max-width: 1080px; | |
| 9057 | + margin: 0 auto; | |
| 9058 | +} | |
| 9059 | +.shithub-global-sidebar { | |
| 9060 | + position: sticky; | |
| 9061 | + top: 1rem; | |
| 9062 | + align-self: start; | |
| 9063 | + min-width: 0; | |
| 9064 | +} | |
| 9065 | +.shithub-global-side-nav { | |
| 9066 | + display: grid; | |
| 9067 | + gap: 0.15rem; | |
| 9068 | + padding-bottom: 1.25rem; | |
| 9069 | +} | |
| 9070 | +.shithub-global-side-link { | |
| 9071 | + position: relative; | |
| 9072 | + display: flex; | |
| 9073 | + align-items: center; | |
| 9074 | + gap: 0.6rem; | |
| 9075 | + min-height: 36px; | |
| 9076 | + padding: 0.45rem 0.75rem; | |
| 9077 | + border-radius: 6px; | |
| 9078 | + color: var(--fg-default); | |
| 9079 | + font-weight: 600; | |
| 9080 | +} | |
| 9081 | +.shithub-global-side-link svg { | |
| 9082 | + color: var(--fg-muted); | |
| 9083 | +} | |
| 9084 | +.shithub-global-side-link:hover { | |
| 9085 | + background: var(--canvas-subtle); | |
| 9086 | + text-decoration: none; | |
| 9087 | +} | |
| 9088 | +.shithub-global-side-link.is-selected { | |
| 9089 | + background: var(--canvas-subtle); | |
| 9090 | +} | |
| 9091 | +.shithub-global-side-link.is-selected::before { | |
| 9092 | + content: ""; | |
| 9093 | + position: absolute; | |
| 9094 | + left: 0; | |
| 9095 | + top: 6px; | |
| 9096 | + bottom: 6px; | |
| 9097 | + width: 4px; | |
| 9098 | + border-radius: 999px; | |
| 9099 | + background: var(--accent-emphasis); | |
| 9100 | +} | |
| 9101 | +.shithub-global-side-section { | |
| 9102 | + display: grid; | |
| 9103 | + grid-template-columns: minmax(0, 1fr) auto; | |
| 9104 | + gap: 0.4rem; | |
| 9105 | + padding: 1.25rem 0.75rem 0; | |
| 9106 | + border-top: 1px solid var(--border-default); | |
| 9107 | +} | |
| 9108 | +.shithub-global-side-section h2 { | |
| 9109 | + margin: 0; | |
| 9110 | + color: var(--fg-muted); | |
| 9111 | + font-size: 0.8rem; | |
| 9112 | +} | |
| 9113 | +.shithub-global-side-section p { | |
| 9114 | + grid-column: 1 / -1; | |
| 9115 | + margin: 0; | |
| 9116 | + color: var(--fg-muted); | |
| 9117 | + font-size: 0.8rem; | |
| 9118 | +} | |
| 9119 | +.shithub-global-side-add { | |
| 9120 | + display: inline-flex; | |
| 9121 | + align-items: center; | |
| 9122 | + justify-content: center; | |
| 9123 | + width: 24px; | |
| 9124 | + height: 24px; | |
| 9125 | + padding: 0; | |
| 9126 | + border: 0; | |
| 9127 | + background: transparent; | |
| 9128 | + color: var(--fg-muted); | |
| 9129 | +} | |
| 9130 | +.shithub-global-main { | |
| 9131 | + min-width: 0; | |
| 9132 | +} | |
| 9133 | +.shithub-global-head { | |
| 9134 | + display: flex; | |
| 9135 | + align-items: center; | |
| 9136 | + justify-content: space-between; | |
| 9137 | + gap: 1rem; | |
| 9138 | + margin-bottom: 0.85rem; | |
| 9139 | +} | |
| 9140 | +.shithub-global-head-compact { | |
| 9141 | + margin-bottom: 0.75rem; | |
| 9142 | +} | |
| 9143 | +.shithub-global-head h1 { | |
| 9144 | + margin: 0; | |
| 9145 | + font-size: 1.5rem; | |
| 9146 | + line-height: 1.25; | |
| 9147 | +} | |
| 9148 | +.shithub-global-query { | |
| 9149 | + display: flex; | |
| 9150 | + align-items: stretch; | |
| 9151 | + width: 100%; | |
| 9152 | + margin: 0 0 1rem; | |
| 9153 | +} | |
| 9154 | +.shithub-global-query-inline { | |
| 9155 | + flex: 1 1 28rem; | |
| 9156 | + margin: 0; | |
| 9157 | +} | |
| 9158 | +.shithub-global-query input[type="text"] { | |
| 9159 | + flex: 1; | |
| 9160 | + min-width: 0; | |
| 9161 | + min-height: 36px; | |
| 9162 | + padding: 0.45rem 0.85rem; | |
| 9163 | + border-radius: 6px 0 0 6px; | |
| 9164 | +} | |
| 9165 | +.shithub-global-query button { | |
| 9166 | + display: inline-flex; | |
| 9167 | + align-items: center; | |
| 9168 | + justify-content: center; | |
| 9169 | + width: 44px; | |
| 9170 | + border: 1px solid var(--border-default); | |
| 9171 | + border-left: 0; | |
| 9172 | + border-radius: 0 6px 6px 0; | |
| 9173 | + color: var(--fg-muted); | |
| 9174 | + background: var(--button-default-bg); | |
| 9175 | + cursor: pointer; | |
| 9176 | +} | |
| 9177 | +.shithub-global-query button:hover { | |
| 9178 | + color: var(--fg-default); | |
| 9179 | + background: var(--button-default-hover-bg); | |
| 9180 | +} | |
| 9181 | +.shithub-global-toolbar-row { | |
| 9182 | + display: flex; | |
| 9183 | + align-items: stretch; | |
| 9184 | + gap: 0.75rem; | |
| 9185 | + margin-bottom: 1rem; | |
| 9186 | +} | |
| 9187 | +.shithub-global-top-tabs { | |
| 9188 | + display: inline-flex; | |
| 9189 | + flex: 0 0 auto; | |
| 9190 | + border: 1px solid var(--border-default); | |
| 9191 | + border-radius: 6px; | |
| 9192 | + overflow: hidden; | |
| 9193 | +} | |
| 9194 | +.shithub-global-top-tabs a { | |
| 9195 | + display: inline-flex; | |
| 9196 | + align-items: center; | |
| 9197 | + min-height: 36px; | |
| 9198 | + padding: 0.45rem 0.9rem; | |
| 9199 | + border-left: 1px solid var(--border-default); | |
| 9200 | + color: var(--fg-default); | |
| 9201 | + font-weight: 600; | |
| 9202 | +} | |
| 9203 | +.shithub-global-top-tabs a:first-child { | |
| 9204 | + border-left: 0; | |
| 9205 | +} | |
| 9206 | +.shithub-global-top-tabs a:hover { | |
| 9207 | + background: var(--canvas-subtle); | |
| 9208 | + text-decoration: none; | |
| 9209 | +} | |
| 9210 | +.shithub-global-top-tabs a.is-selected { | |
| 9211 | + background: var(--accent-emphasis); | |
| 9212 | + color: #fff; | |
| 9213 | +} | |
| 9214 | +.shithub-global-panel { | |
| 9215 | + border: 1px solid var(--border-default); | |
| 9216 | + border-radius: 6px; | |
| 9217 | + overflow: hidden; | |
| 9218 | + background: var(--canvas-default); | |
| 9219 | +} | |
| 9220 | +.shithub-global-panel-head { | |
| 9221 | + display: flex; | |
| 9222 | + align-items: center; | |
| 9223 | + justify-content: space-between; | |
| 9224 | + gap: 1rem; | |
| 9225 | + min-height: 52px; | |
| 9226 | + padding: 0.75rem 1rem; | |
| 9227 | + border-bottom: 1px solid var(--border-default); | |
| 9228 | + background: var(--canvas-subtle); | |
| 9229 | +} | |
| 9230 | +.shithub-global-state-tabs, | |
| 9231 | +.shithub-global-filter-set { | |
| 9232 | + display: inline-flex; | |
| 9233 | + align-items: center; | |
| 9234 | + gap: 0.75rem; | |
| 9235 | + flex-wrap: wrap; | |
| 9236 | +} | |
| 9237 | +.shithub-global-state-tabs a { | |
| 9238 | + display: inline-flex; | |
| 9239 | + align-items: center; | |
| 9240 | + gap: 0.35rem; | |
| 9241 | + color: var(--fg-muted); | |
| 9242 | + font-weight: 600; | |
| 9243 | +} | |
| 9244 | +.shithub-global-state-tabs a.is-selected, | |
| 9245 | +.shithub-global-state-tabs a:hover { | |
| 9246 | + color: var(--fg-default); | |
| 9247 | + text-decoration: none; | |
| 9248 | +} | |
| 9249 | +.shithub-global-filter-set button, | |
| 9250 | +.shithub-global-sort summary { | |
| 9251 | + display: inline-flex; | |
| 9252 | + align-items: center; | |
| 9253 | + gap: 0.35rem; | |
| 9254 | + border: 0; | |
| 9255 | + background: transparent; | |
| 9256 | + color: var(--fg-muted); | |
| 9257 | + font: inherit; | |
| 9258 | + font-weight: 600; | |
| 9259 | +} | |
| 9260 | +.shithub-global-filter-set button { | |
| 9261 | + cursor: default; | |
| 9262 | +} | |
| 9263 | +.shithub-global-sort { | |
| 9264 | + position: relative; | |
| 9265 | +} | |
| 9266 | +.shithub-global-sort summary { | |
| 9267 | + list-style: none; | |
| 9268 | + cursor: pointer; | |
| 9269 | +} | |
| 9270 | +.shithub-global-sort summary::-webkit-details-marker { | |
| 9271 | + display: none; | |
| 9272 | +} | |
| 9273 | +.shithub-global-sort div { | |
| 9274 | + position: absolute; | |
| 9275 | + right: 0; | |
| 9276 | + top: calc(100% + 0.4rem); | |
| 9277 | + min-width: 160px; | |
| 9278 | + padding: 0.45rem; | |
| 9279 | + border: 1px solid var(--border-default); | |
| 9280 | + border-radius: 6px; | |
| 9281 | + background: var(--canvas-default); | |
| 9282 | + box-shadow: 0 10px 24px rgba(1, 4, 9, 0.32); | |
| 9283 | +} | |
| 9284 | +.shithub-global-sort span { | |
| 9285 | + display: block; | |
| 9286 | + padding: 0.35rem 0.45rem; | |
| 9287 | + color: var(--fg-default); | |
| 9288 | + font-size: 0.875rem; | |
| 9289 | +} | |
| 9290 | +.shithub-global-thread-list, | |
| 9291 | +.shithub-global-repo-list { | |
| 9292 | + margin: 0; | |
| 9293 | + padding: 0; | |
| 9294 | + list-style: none; | |
| 9295 | +} | |
| 9296 | +.shithub-global-thread-row { | |
| 9297 | + display: grid; | |
| 9298 | + grid-template-columns: 20px minmax(0, 1fr) auto; | |
| 9299 | + gap: 0.85rem; | |
| 9300 | + align-items: start; | |
| 9301 | + padding: 1rem; | |
| 9302 | + border-top: 1px solid var(--border-default); | |
| 9303 | +} | |
| 9304 | +.shithub-global-thread-row:first-child, | |
| 9305 | +.shithub-global-repo-row:first-child { | |
| 9306 | + border-top: 0; | |
| 9307 | +} | |
| 9308 | +.shithub-global-thread-state { | |
| 9309 | + display: inline-flex; | |
| 9310 | + align-items: center; | |
| 9311 | + justify-content: center; | |
| 9312 | + width: 20px; | |
| 9313 | + height: 20px; | |
| 9314 | + margin-top: 0.15rem; | |
| 9315 | +} | |
| 9316 | +.shithub-global-thread-state-open svg { | |
| 9317 | + color: var(--success-fg); | |
| 9318 | +} | |
| 9319 | +.shithub-global-thread-state-closed svg, | |
| 9320 | +.shithub-global-thread-state-pr svg { | |
| 9321 | + color: var(--accent-fg); | |
| 9322 | +} | |
| 9323 | +.shithub-global-thread-main { | |
| 9324 | + min-width: 0; | |
| 9325 | +} | |
| 9326 | +.shithub-global-thread-title { | |
| 9327 | + color: var(--fg-default); | |
| 9328 | + font-size: 1rem; | |
| 9329 | + font-weight: 700; | |
| 9330 | +} | |
| 9331 | +.shithub-global-thread-title span { | |
| 9332 | + color: var(--fg-muted); | |
| 9333 | +} | |
| 9334 | +.shithub-global-thread-title:hover { | |
| 9335 | + color: var(--accent-fg); | |
| 9336 | + text-decoration: none; | |
| 9337 | +} | |
| 9338 | +.shithub-global-thread-main p { | |
| 9339 | + margin: 0.2rem 0 0; | |
| 9340 | + color: var(--fg-muted); | |
| 9341 | + font-size: 0.85rem; | |
| 9342 | +} | |
| 9343 | +.shithub-global-thread-comments { | |
| 9344 | + display: inline-flex; | |
| 9345 | + align-items: center; | |
| 9346 | + gap: 0.3rem; | |
| 9347 | + color: var(--fg-muted); | |
| 9348 | + font-size: 0.85rem; | |
| 9349 | +} | |
| 9350 | +.shithub-global-repo-row { | |
| 9351 | + display: grid; | |
| 9352 | + grid-template-columns: minmax(0, 1fr) 140px; | |
| 9353 | + gap: 1rem; | |
| 9354 | + align-items: center; | |
| 9355 | + padding: 1rem; | |
| 9356 | + border-top: 1px solid var(--border-default); | |
| 9357 | +} | |
| 9358 | +.shithub-global-repo-row-action { | |
| 9359 | + grid-template-columns: minmax(0, 1fr) auto; | |
| 9360 | +} | |
| 9361 | +.shithub-global-repo-main { | |
| 9362 | + min-width: 0; | |
| 9363 | +} | |
| 9364 | +.shithub-global-repo-main h2 { | |
| 9365 | + display: flex; | |
| 9366 | + align-items: center; | |
| 9367 | + gap: 0.45rem; | |
| 9368 | + flex-wrap: wrap; | |
| 9369 | + margin: 0; | |
| 9370 | + font-size: 1rem; | |
| 9371 | +} | |
| 9372 | +.shithub-global-repo-main h2 a { | |
| 9373 | + color: var(--fg-default); | |
| 9374 | +} | |
| 9375 | +.shithub-global-repo-main h2 a:hover { | |
| 9376 | + color: var(--accent-fg); | |
| 9377 | + text-decoration: none; | |
| 9378 | +} | |
| 9379 | +.shithub-global-repo-main p { | |
| 9380 | + margin: 0.35rem 0 0; | |
| 9381 | + color: var(--fg-muted); | |
| 9382 | +} | |
| 9383 | +.shithub-global-repo-meta { | |
| 9384 | + display: flex; | |
| 9385 | + flex-wrap: wrap; | |
| 9386 | + gap: 0.45rem 0.7rem; | |
| 9387 | + margin: 0.55rem 0 0; | |
| 9388 | + padding: 0; | |
| 9389 | + color: var(--fg-muted); | |
| 9390 | + list-style: none; | |
| 9391 | + font-size: 0.82rem; | |
| 9392 | +} | |
| 9393 | +.shithub-global-repo-meta li { | |
| 9394 | + display: inline-flex; | |
| 9395 | + align-items: center; | |
| 9396 | + gap: 0.25rem; | |
| 9397 | +} | |
| 9398 | +.shithub-global-sparkline { | |
| 9399 | + justify-self: end; | |
| 9400 | + width: 112px; | |
| 9401 | + height: 28px; | |
| 9402 | + border-bottom: 1px solid rgba(63, 185, 80, 0.65); | |
| 9403 | + background: | |
| 9404 | + linear-gradient(90deg, transparent 0 78%, rgba(63, 185, 80, 0.45) 78% 80%, transparent 80% 88%, rgba(63, 185, 80, 0.7) 88% 90%, transparent 90%), | |
| 9405 | + linear-gradient(180deg, transparent 0 58%, rgba(63, 185, 80, 0.2) 58% 61%, transparent 61%); | |
| 9406 | +} | |
| 9407 | +.shithub-global-empty { | |
| 9408 | + padding: 2.25rem 1rem; | |
| 9409 | + text-align: center; | |
| 9410 | +} | |
| 9411 | +.shithub-global-empty h2 { | |
| 9412 | + margin: 0; | |
| 9413 | + font-size: 1rem; | |
| 9414 | +} | |
| 9415 | +.shithub-global-empty p, | |
| 9416 | +.shithub-global-muted { | |
| 9417 | + margin: 0.35rem 0 0; | |
| 9418 | + color: var(--fg-muted); | |
| 9419 | +} | |
| 9420 | +.shithub-global-protip { | |
| 9421 | + margin: 1.25rem 0 0; | |
| 9422 | + color: var(--fg-muted); | |
| 9423 | + text-align: center; | |
| 9424 | +} | |
| 9425 | +@media (max-width: 1080px) { | |
| 9426 | + .shithub-nav-actions { | |
| 9427 | + gap: 0.35rem; | |
| 9428 | + } | |
| 9429 | + .shithub-nav-actions-divider, | |
| 9430 | + .shithub-nav-actions > a[aria-label="Your organizations"] { | |
| 9431 | + display: none; | |
| 9432 | + } | |
| 9433 | +} | |
| 9434 | +@media (max-width: 900px) { | |
| 9435 | + .shithub-global-shell, | |
| 9436 | + .shithub-global-shell-wide { | |
| 9437 | + grid-template-columns: 1fr; | |
| 9438 | + gap: 1rem; | |
| 9439 | + } | |
| 9440 | + .shithub-global-sidebar { | |
| 9441 | + position: static; | |
| 9442 | + } | |
| 9443 | + .shithub-global-side-nav { | |
| 9444 | + display: flex; | |
| 9445 | + overflow-x: auto; | |
| 9446 | + padding-bottom: 0.5rem; | |
| 9447 | + } | |
| 9448 | + .shithub-global-side-link { | |
| 9449 | + flex: 0 0 auto; | |
| 9450 | + } | |
| 9451 | + .shithub-global-side-section { | |
| 9452 | + display: none; | |
| 9453 | + } | |
| 9454 | + .shithub-global-toolbar-row { | |
| 9455 | + flex-direction: column; | |
| 9456 | + } | |
| 9457 | + .shithub-global-top-tabs { | |
| 9458 | + overflow-x: auto; | |
| 9459 | + } | |
| 9460 | +} | |
| 9461 | +@media (max-width: 640px) { | |
| 9462 | + .shithub-nav-actions > a[aria-label="All issues"], | |
| 9463 | + .shithub-nav-actions > a[aria-label="All pull requests"] { | |
| 9464 | + display: none; | |
| 9465 | + } | |
| 9466 | + .shithub-global-page { | |
| 9467 | + padding: 1rem 0.75rem 2rem; | |
| 9468 | + } | |
| 9469 | + .shithub-global-head, | |
| 9470 | + .shithub-global-panel-head { | |
| 9471 | + align-items: flex-start; | |
| 9472 | + flex-direction: column; | |
| 9473 | + } | |
| 9474 | + .shithub-global-thread-row, | |
| 9475 | + .shithub-global-repo-row, | |
| 9476 | + .shithub-global-repo-row-action { | |
| 9477 | + grid-template-columns: 1fr; | |
| 9478 | + } | |
| 9479 | + .shithub-global-thread-state, | |
| 9480 | + .shithub-global-sparkline { | |
| 9481 | + display: none; | |
| 9482 | + } | |
| 9483 | +} | |
| 9484 | + | |
| 8974 | 9485 | /* S42 — social dashboard and Explore feed. */ |
| 8975 | 9486 | .shithub-dashboard-page, |
| 8976 | 9487 | .shithub-explore-page { |
internal/web/templates/dashboard/issues.htmladded@@ -0,0 +1,70 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-global-page"> | |
| 3 | + <div class="shithub-global-shell"> | |
| 4 | + <aside class="shithub-global-sidebar" aria-label="Issue views"> | |
| 5 | + <nav class="shithub-global-side-nav"> | |
| 6 | + {{ range .Views }} | |
| 7 | + <a href="{{ .Href }}" class="shithub-global-side-link{{ if .Selected }} is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}> | |
| 8 | + {{ octicon .Icon }} <span>{{ .Label }}</span> | |
| 9 | + </a> | |
| 10 | + {{ end }} | |
| 11 | + </nav> | |
| 12 | + <div class="shithub-global-side-section"> | |
| 13 | + <h2>Views</h2> | |
| 14 | + <button type="button" class="shithub-global-side-add" disabled aria-label="Create saved view">{{ octicon "plus" }}</button> | |
| 15 | + <p>No saved views</p> | |
| 16 | + </div> | |
| 17 | + </aside> | |
| 18 | + | |
| 19 | + <main class="shithub-global-main"> | |
| 20 | + <header class="shithub-global-head"> | |
| 21 | + <h1>{{ .Heading }}</h1> | |
| 22 | + <a href="{{ .NewHref }}" class="shithub-button shithub-button-primary">New issue</a> | |
| 23 | + </header> | |
| 24 | + | |
| 25 | + <form action="" method="get" class="shithub-global-query"> | |
| 26 | + <label class="sr-only" for="global-issues-query">Search issues</label> | |
| 27 | + <input id="global-issues-query" type="text" name="q" value="{{ .Query }}" autocomplete="off"> | |
| 28 | + <button type="submit" aria-label="Search issues">{{ octicon "search" }}</button> | |
| 29 | + </form> | |
| 30 | + | |
| 31 | + <section class="shithub-global-panel" aria-label="Issues"> | |
| 32 | + <div class="shithub-global-panel-head"> | |
| 33 | + <strong>{{ .ResultCount }} results</strong> | |
| 34 | + <nav class="shithub-global-state-tabs" aria-label="Issue state"> | |
| 35 | + {{ range .StateTabs }} | |
| 36 | + <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>{{ octicon .Icon }} {{ .Count }} {{ .Label }}</a> | |
| 37 | + {{ end }} | |
| 38 | + </nav> | |
| 39 | + <details class="shithub-global-sort"> | |
| 40 | + <summary>{{ octicon "list-unordered" }} Updated</summary> | |
| 41 | + <div role="menu"><span role="menuitem">Newest updated</span></div> | |
| 42 | + </details> | |
| 43 | + </div> | |
| 44 | + | |
| 45 | + {{ if .Issues }} | |
| 46 | + <ol class="shithub-global-thread-list"> | |
| 47 | + {{ range .Issues }} | |
| 48 | + <li class="shithub-global-thread-row"> | |
| 49 | + <span class="shithub-global-thread-state shithub-global-thread-state-{{ .State }}">{{ if eq .State "closed" }}{{ octicon "issue-closed" }}{{ else }}{{ octicon "issue-opened" }}{{ end }}</span> | |
| 50 | + <div class="shithub-global-thread-main"> | |
| 51 | + <a class="shithub-global-thread-title" href="{{ .URL }}">{{ .Title }}</a> | |
| 52 | + <p><a href="{{ .RepoURL }}">{{ .Owner }}/{{ .RepoName }}</a>#{{ .Number }} opened by {{ if .AuthorName }}{{ .AuthorName }}{{ else }}ghost{{ end }} · Updated {{ relativeTime .UpdatedAt }}</p> | |
| 53 | + </div> | |
| 54 | + {{ if gt .CommentCount 0 }} | |
| 55 | + <a class="shithub-global-thread-comments" href="{{ .URL }}">{{ octicon "comment" }} {{ .CommentCount }}</a> | |
| 56 | + {{ end }} | |
| 57 | + </li> | |
| 58 | + {{ end }} | |
| 59 | + </ol> | |
| 60 | + {{ else }} | |
| 61 | + <div class="shithub-global-empty"> | |
| 62 | + <h2>{{ .EmptyTitle }}</h2> | |
| 63 | + <p>{{ .EmptyMessage }}</p> | |
| 64 | + </div> | |
| 65 | + {{ end }} | |
| 66 | + </section> | |
| 67 | + </main> | |
| 68 | + </div> | |
| 69 | +</section> | |
| 70 | +{{- end }} | |
internal/web/templates/dashboard/new_issue.htmladded@@ -0,0 +1,42 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-global-page"> | |
| 3 | + <div class="shithub-global-centered"> | |
| 4 | + <main class="shithub-global-main"> | |
| 5 | + <header class="shithub-global-head"> | |
| 6 | + <h1>{{ .Heading }}</h1> | |
| 7 | + </header> | |
| 8 | + | |
| 9 | + <form action="/issues/new" method="get" class="shithub-global-query"> | |
| 10 | + <label class="sr-only" for="global-new-issue-query">Find a repository</label> | |
| 11 | + <input id="global-new-issue-query" type="text" name="q" value="{{ .Query }}" placeholder="Find a repository..." autocomplete="off"> | |
| 12 | + <button type="submit" aria-label="Search repositories">{{ octicon "search" }}</button> | |
| 13 | + </form> | |
| 14 | + | |
| 15 | + <section class="shithub-global-panel" aria-label="Choose repository"> | |
| 16 | + <div class="shithub-global-panel-head"> | |
| 17 | + <strong>Choose a repository</strong> | |
| 18 | + <span class="shithub-global-muted">{{ .TotalCount }} available</span> | |
| 19 | + </div> | |
| 20 | + {{ if .Repos }} | |
| 21 | + <ol class="shithub-global-repo-list"> | |
| 22 | + {{ range .Repos }} | |
| 23 | + <li class="shithub-global-repo-row shithub-global-repo-row-action"> | |
| 24 | + <div class="shithub-global-repo-main"> | |
| 25 | + <h2><a href="{{ .URL }}">{{ .Owner }}/{{ .Name }}</a> {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}</h2> | |
| 26 | + {{ if .Description }}<p>{{ .Description }}</p>{{ end }} | |
| 27 | + </div> | |
| 28 | + <a class="shithub-button" href="{{ .URL }}/issues/new">New issue</a> | |
| 29 | + </li> | |
| 30 | + {{ end }} | |
| 31 | + </ol> | |
| 32 | + {{ else }} | |
| 33 | + <div class="shithub-global-empty"> | |
| 34 | + <h2>{{ .EmptyTitle }}</h2> | |
| 35 | + <p>{{ .EmptyBody }}</p> | |
| 36 | + </div> | |
| 37 | + {{ end }} | |
| 38 | + </section> | |
| 39 | + </main> | |
| 40 | + </div> | |
| 41 | +</section> | |
| 42 | +{{- end }} | |
internal/web/templates/dashboard/pulls.htmladded@@ -0,0 +1,64 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-global-page"> | |
| 3 | + <div class="shithub-global-centered"> | |
| 4 | + <main class="shithub-global-main"> | |
| 5 | + <header class="shithub-global-head shithub-global-head-compact"> | |
| 6 | + <h1>{{ .Heading }}</h1> | |
| 7 | + </header> | |
| 8 | + | |
| 9 | + <div class="shithub-global-toolbar-row"> | |
| 10 | + <nav class="shithub-global-top-tabs" aria-label="Pull request views"> | |
| 11 | + {{ range .Views }} | |
| 12 | + <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>{{ .Label }}</a> | |
| 13 | + {{ end }} | |
| 14 | + </nav> | |
| 15 | + <form action="/pulls" method="get" class="shithub-global-query shithub-global-query-inline"> | |
| 16 | + {{ range .Views }}{{ if .Selected }}{{ if ne .Key "created" }}<input type="hidden" name="view" value="{{ .Key }}">{{ end }}{{ end }}{{ end }} | |
| 17 | + <label class="sr-only" for="global-pulls-query">Search pull requests</label> | |
| 18 | + <input id="global-pulls-query" type="text" name="q" value="{{ .Query }}" autocomplete="off"> | |
| 19 | + <button type="submit" aria-label="Search pull requests">{{ octicon "search" }}</button> | |
| 20 | + </form> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <section class="shithub-global-panel" aria-label="Pull requests"> | |
| 24 | + <div class="shithub-global-panel-head"> | |
| 25 | + <nav class="shithub-global-state-tabs" aria-label="Pull request state"> | |
| 26 | + {{ range .StateTabs }} | |
| 27 | + <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>{{ octicon .Icon }} {{ .Count }} {{ .Label }}</a> | |
| 28 | + {{ end }} | |
| 29 | + </nav> | |
| 30 | + <div class="shithub-global-filter-set" aria-label="Pull request filters"> | |
| 31 | + <button type="button" disabled>Visibility {{ octicon "triangle-down" }}</button> | |
| 32 | + <button type="button" disabled>Organization {{ octicon "triangle-down" }}</button> | |
| 33 | + <button type="button" disabled>Sort {{ octicon "triangle-down" }}</button> | |
| 34 | + </div> | |
| 35 | + </div> | |
| 36 | + | |
| 37 | + {{ if .Pulls }} | |
| 38 | + <ol class="shithub-global-thread-list"> | |
| 39 | + {{ range .Pulls }} | |
| 40 | + <li class="shithub-global-thread-row"> | |
| 41 | + <span class="shithub-global-thread-state shithub-global-thread-state-pr">{{ octicon "git-pull-request" }}</span> | |
| 42 | + <div class="shithub-global-thread-main"> | |
| 43 | + <a class="shithub-global-thread-title" href="{{ .URL }}"><span>{{ .Owner }}/{{ .RepoName }}</span> {{ .Title }}</a> | |
| 44 | + <p>#{{ .Number }} opened by {{ if .AuthorName }}{{ .AuthorName }}{{ else }}ghost{{ end }} · Updated {{ relativeTime .UpdatedAt }}</p> | |
| 45 | + </div> | |
| 46 | + {{ if gt .CommentCount 0 }} | |
| 47 | + <a class="shithub-global-thread-comments" href="{{ .URL }}">{{ octicon "comment" }} {{ .CommentCount }}</a> | |
| 48 | + {{ end }} | |
| 49 | + </li> | |
| 50 | + {{ end }} | |
| 51 | + </ol> | |
| 52 | + {{ else }} | |
| 53 | + <div class="shithub-global-empty"> | |
| 54 | + <h2>{{ .EmptyTitle }}</h2> | |
| 55 | + <p>Try another pull request view or search term.</p> | |
| 56 | + </div> | |
| 57 | + {{ end }} | |
| 58 | + </section> | |
| 59 | + | |
| 60 | + <p class="shithub-global-protip">{{ octicon "light-bulb" }} <strong>ProTip!</strong> Mention someone in a pull request with @{{ .Viewer.Username }}.</p> | |
| 61 | + </main> | |
| 62 | + </div> | |
| 63 | +</section> | |
| 64 | +{{- end }} | |
internal/web/templates/dashboard/repos.htmladded@@ -0,0 +1,72 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-global-page"> | |
| 3 | + <div class="shithub-global-shell shithub-global-shell-wide"> | |
| 4 | + <aside class="shithub-global-sidebar" aria-label="Repository views"> | |
| 5 | + <nav class="shithub-global-side-nav"> | |
| 6 | + {{ range .Views }} | |
| 7 | + <a href="{{ .Href }}" class="shithub-global-side-link{{ if .Selected }} is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}> | |
| 8 | + {{ octicon .Icon }} <span>{{ .Label }}</span> | |
| 9 | + </a> | |
| 10 | + {{ end }} | |
| 11 | + </nav> | |
| 12 | + <div class="shithub-global-side-section"> | |
| 13 | + <h2>Views</h2> | |
| 14 | + <button type="button" class="shithub-global-side-add" disabled aria-label="Create saved view">{{ octicon "plus" }}</button> | |
| 15 | + <p>No saved views</p> | |
| 16 | + </div> | |
| 17 | + </aside> | |
| 18 | + | |
| 19 | + <main class="shithub-global-main"> | |
| 20 | + <header class="shithub-global-head"> | |
| 21 | + <h1>{{ .Heading }}</h1> | |
| 22 | + <a href="{{ .NewRepoHref }}" class="shithub-button shithub-button-primary">New repository</a> | |
| 23 | + </header> | |
| 24 | + | |
| 25 | + <form action="/repos" method="get" class="shithub-global-query"> | |
| 26 | + {{ range .Views }}{{ if .Selected }}{{ if ne .Key "contributions" }}<input type="hidden" name="view" value="{{ .Key }}">{{ end }}{{ end }}{{ end }} | |
| 27 | + <label class="sr-only" for="global-repos-query">Search repositories</label> | |
| 28 | + <input id="global-repos-query" type="text" name="q" value="{{ .Query }}" placeholder="Find a repository..." autocomplete="off"> | |
| 29 | + <button type="submit" aria-label="Search repositories">{{ octicon "search" }}</button> | |
| 30 | + </form> | |
| 31 | + | |
| 32 | + <section class="shithub-global-panel" aria-label="Repositories"> | |
| 33 | + <div class="shithub-global-panel-head"> | |
| 34 | + <strong>{{ .TotalCount }} repositories</strong> | |
| 35 | + <div class="shithub-global-filter-set" aria-label="Repository layout"> | |
| 36 | + <button type="button" disabled>{{ octicon "list-unordered" }} Relevance {{ octicon "triangle-down" }}</button> | |
| 37 | + <button type="button" disabled aria-label="List view">{{ octicon "list-unordered" }}</button> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + {{ if .Repos }} | |
| 42 | + <ol class="shithub-global-repo-list"> | |
| 43 | + {{ range .Repos }} | |
| 44 | + <li class="shithub-global-repo-row"> | |
| 45 | + <div class="shithub-global-repo-main"> | |
| 46 | + <h2><a href="{{ .URL }}">{{ .Owner }}/{{ .Name }}</a> {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}</h2> | |
| 47 | + {{ if .Description }}<p>{{ .Description }}</p>{{ end }} | |
| 48 | + <ul class="shithub-global-repo-meta"> | |
| 49 | + {{ if .PrimaryLanguage }}<li><span class="shithub-lang-dot" aria-hidden="true"></span>{{ .PrimaryLanguage }}</li>{{ end }} | |
| 50 | + {{ if .LicenseKey }}<li>{{ octicon "law" }} {{ .LicenseKey }}</li>{{ end }} | |
| 51 | + <li>{{ octicon "repo-forked" }} {{ .ForkCount }}</li> | |
| 52 | + <li>{{ octicon "star" }} {{ .StarCount }}</li> | |
| 53 | + <li>{{ octicon "eye" }} {{ .WatcherCount }}</li> | |
| 54 | + {{ if .IsFork }}<li>Fork</li>{{ end }} | |
| 55 | + <li>Updated {{ relativeTime .UpdatedAt }}</li> | |
| 56 | + </ul> | |
| 57 | + </div> | |
| 58 | + <span class="shithub-global-sparkline" aria-hidden="true"></span> | |
| 59 | + </li> | |
| 60 | + {{ end }} | |
| 61 | + </ol> | |
| 62 | + {{ else }} | |
| 63 | + <div class="shithub-global-empty"> | |
| 64 | + <h2>{{ .EmptyTitle }}</h2> | |
| 65 | + <p>{{ .EmptyBody }}</p> | |
| 66 | + </div> | |
| 67 | + {{ end }} | |
| 68 | + </section> | |
| 69 | + </main> | |
| 70 | + </div> | |
| 71 | +</section> | |
| 72 | +{{- end }} | |