feat: add repository backgrounds and solid page surfaces
a923c5f
Browse files
R
rin
·
2026-10-17 06:45 UTC
10 files changed
+216
-74
internal/db/migrations/007_repository_background.sql
+1
-0
| ... | ... | @@ -0,0 +1 @@ |
| 1 | +ALTER TABLE repositories ADD COLUMN background_url TEXT NOT NULL DEFAULT ''; |
internal/i18n/i18n.go
+8
-0
| ... | ... | @@ -553,6 +553,10 @@ var translations = map[Lang]map[string]string{ |
| 553 | 553 | "repo.avatar_upload": "Выбрать изображение", |
| 554 | 554 | "repo.avatar_remove": "Удалить аватар", |
| 555 | 555 | "repo.avatar_hint": "Квадратное изображение до 2 МБ. Без аватара используется изображение владельца.", |
| 556 | + "repo.background": "Фон репозитория", | |
| 557 | + "repo.background_upload": "Выбрать фон", | |
| 558 | + "repo.background_remove": "Убрать фон", | |
| 559 | + "repo.background_hint": "PNG, JPG, WebP или GIF до 6 МБ. Изображение мягко растворяется в цвет текущей темы.", | |
| 556 | 560 | "admin.registration.public": "Публичная регистрация", |
| 557 | 561 | "admin.registration.confirm": "Регистрация с подтверждением", |
| 558 | 562 | "admin.registration.closed": "Без регистрации", |
| ... | ... | @@ -1132,6 +1136,10 @@ var translations = map[Lang]map[string]string{ |
| 1132 | 1136 | "repo.avatar_upload": "Choose image", |
| 1133 | 1137 | "repo.avatar_remove": "Remove avatar", |
| 1134 | 1138 | "repo.avatar_hint": "A square image up to 2 MB. The owner avatar is used when empty.", |
| 1139 | + "repo.background": "Repository background", | |
| 1140 | + "repo.background_upload": "Choose background", | |
| 1141 | + "repo.background_remove": "Remove background", | |
| 1142 | + "repo.background_hint": "PNG, JPG, WebP, or GIF up to 6 MB. The image fades into the current theme.", | |
| 1135 | 1143 | "admin.registration.public": "Public Registration", |
| 1136 | 1144 | "admin.registration.confirm": "Confirm-only Registration", |
| 1137 | 1145 | "admin.registration.closed": "No Registration", |
internal/repo/repo.go
+16
-3
| ... | ... | @@ -40,6 +40,7 @@ type Repository struct { |
| 40 | 40 | Name string |
| 41 | 41 | Description string |
| 42 | 42 | AvatarURL string |
| 43 | + BackgroundURL string | |
| 43 | 44 | IsPrivate bool |
| 44 | 45 | DefaultBranch string |
| 45 | 46 | EnableIssues bool |
| ... | ... | @@ -181,7 +182,7 @@ func (s *Service) Create(ctx context.Context, owner *user.User, name, descriptio |
| 181 | 182 | } |
| 182 | 183 | |
| 183 | 184 | const repoSelectFields = ` |
| 184 | - SELECT r.id, r.owner_id, u.username, u.avatar_url, r.avatar_url, r.name, r.description, r.is_private, r.default_branch, | |
| 185 | + SELECT r.id, r.owner_id, u.username, u.avatar_url, r.avatar_url, r.background_url, r.name, r.description, r.is_private, r.default_branch, | |
| 185 | 186 | r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity, |
| 186 | 187 | r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name, |
| 187 | 188 | (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count, |
| ... | ... | @@ -347,6 +348,18 @@ func (s *Service) UpdateAvatar(ctx context.Context, repoID int64, avatarURL stri |
| 347 | 348 | return nil |
| 348 | 349 | } |
| 349 | 350 | |
| 351 | +func (s *Service) UpdateBackground(ctx context.Context, repoID int64, backgroundURL string) error { | |
| 352 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 353 | + defer cancel() | |
| 354 | + _, err := s.db.ExecContext(cctx, ` | |
| 355 | + UPDATE repositories SET background_url = ?, updated_at = ? WHERE id = ? | |
| 356 | + `, strings.TrimSpace(backgroundURL), time.Now().UTC(), repoID) | |
| 357 | + if err != nil { | |
| 358 | + return fmt.Errorf("update repository background: %w", err) | |
| 359 | + } | |
| 360 | + return nil | |
| 361 | +} | |
| 362 | + | |
| 350 | 363 | func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) { |
| 351 | 364 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 352 | 365 | defer cancel() |
| ... | ... | @@ -580,7 +593,7 @@ func scanRepo(row *sql.Row) (*Repository, error) { |
| 580 | 593 | var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int |
| 581 | 594 | var forkedFromID sql.NullInt64 |
| 582 | 595 | var forkedFromOwner, forkedFromName sql.NullString |
| 583 | - err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 596 | + err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.BackgroundURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 584 | 597 | &enableIssues, &enablePulls, &enableReleases, &enableActivity, |
| 585 | 598 | &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, |
| 586 | 599 | &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, |
| ... | ... | @@ -612,7 +625,7 @@ func scanRepos(rows *sql.Rows) ([]Repository, error) { |
| 612 | 625 | var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int |
| 613 | 626 | var forkedFromID sql.NullInt64 |
| 614 | 627 | var forkedFromOwner, forkedFromName sql.NullString |
| 615 | - if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 628 | + if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.BackgroundURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 616 | 629 | &enableIssues, &enablePulls, &enableReleases, &enableActivity, |
| 617 | 630 | &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, |
| 618 | 631 | &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, |
internal/web/handlers/account_admin_test.go
+31
-5
| ... | ... | @@ -29,7 +29,7 @@ func authenticatedFormRequest(t *testing.T, method, path string, form url.Values |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | func TestRegistrationModesMediaPersistence(t *testing.T) { |
| 32 | - app, _, userSvc, repoSvc := setupTestApp(t) | |
| 32 | + app, authSvc, userSvc, repoSvc := setupTestApp(t) | |
| 33 | 33 | ctx := context.Background() |
| 34 | 34 | |
| 35 | 35 | admin, err := userSvc.RegisterWithMode(ctx, "admin1", "admin1@example.com", "password123", db.RegistrationModePublic) |
| ... | ... | @@ -92,12 +92,32 @@ func TestRegistrationModesMediaPersistence(t *testing.T) { |
| 92 | 92 | if err := repoSvc.UpdateAvatar(ctx, repository.ID, repoAvatar); err != nil { |
| 93 | 93 | t.Fatalf("update repository avatar: %v", err) |
| 94 | 94 | } |
| 95 | + repoBackground := "data:image/webp;base64,DDDD" | |
| 96 | + sess, err := authSvc.CreateSession(ctx, member.ID) | |
| 97 | + if err != nil { | |
| 98 | + t.Fatalf("create member session: %v", err) | |
| 99 | + } | |
| 100 | + settingsReq := authenticatedFormRequest(t, http.MethodPost, "/member1/media-repo/settings", url.Values{ | |
| 101 | + "_csrf": {sess.CSRFToken}, | |
| 102 | + "description": {"Media persistence"}, | |
| 103 | + "default_branch": {"main"}, | |
| 104 | + "visibility": {"public"}, | |
| 105 | + "enable_issues": {"true"}, | |
| 106 | + "enable_pulls": {"true"}, | |
| 107 | + "enable_releases": {"true"}, | |
| 108 | + "enable_activity": {"true"}, | |
| 109 | + "background_data": {repoBackground}, | |
| 110 | + }, sess) | |
| 111 | + settingsResp, err := app.Test(settingsReq, 5000) | |
| 112 | + if err != nil || settingsResp.StatusCode != http.StatusSeeOther { | |
| 113 | + t.Fatalf("repository settings response: status=%v err=%v", settingsResp.StatusCode, err) | |
| 114 | + } | |
| 95 | 115 | storedRepo, err := repoSvc.GetByID(ctx, repository.ID) |
| 96 | 116 | if err != nil { |
| 97 | 117 | t.Fatalf("load repository: %v", err) |
| 98 | 118 | } |
| 99 | - if storedRepo.AvatarURL != repoAvatar { | |
| 100 | - t.Fatalf("repository avatar = %q, want %q", storedRepo.AvatarURL, repoAvatar) | |
| 119 | + if storedRepo.AvatarURL != repoAvatar || storedRepo.BackgroundURL != repoBackground { | |
| 120 | + t.Fatalf("repository media = avatar %q background %q", storedRepo.AvatarURL, storedRepo.BackgroundURL) | |
| 101 | 121 | } |
| 102 | 122 | |
| 103 | 123 | profileResp, err := app.Test(httptest.NewRequest(http.MethodGet, "/member1", nil), 5000) |
| ... | ... | @@ -105,8 +125,11 @@ func TestRegistrationModesMediaPersistence(t *testing.T) { |
| 105 | 125 | t.Fatalf("profile response: status=%v err=%v", profileResp.StatusCode, err) |
| 106 | 126 | } |
| 107 | 127 | profileBody, _ := io.ReadAll(profileResp.Body) |
| 108 | - if !strings.Contains(string(profileBody), background) || !strings.Contains(string(profileBody), "profile-hero-bg") { | |
| 109 | - t.Fatal("profile page must render the saved vignette background") | |
| 128 | + if !strings.Contains(string(profileBody), background) || !strings.Contains(string(profileBody), "page-bg") { | |
| 129 | + t.Fatal("profile page must render the saved page background") | |
| 130 | + } | |
| 131 | + if strings.Contains(string(profileBody), "profile-hero-bg") || strings.Contains(string(profileBody), "profile-hero has-bg") { | |
| 132 | + t.Fatal("profile background must not be rendered inside the profile header") | |
| 110 | 133 | } |
| 111 | 134 | |
| 112 | 135 | repoResp, err := app.Test(httptest.NewRequest(http.MethodGet, "/member1/media-repo", nil), 5000) |
| ... | ... | @@ -117,6 +140,9 @@ func TestRegistrationModesMediaPersistence(t *testing.T) { |
| 117 | 140 | if !strings.Contains(string(repoBody), repoAvatar) { |
| 118 | 141 | t.Fatal("repository page must render the custom repository avatar") |
| 119 | 142 | } |
| 143 | + if !strings.Contains(string(repoBody), repoBackground) || !strings.Contains(string(repoBody), "page-bg") { | |
| 144 | + t.Fatal("repository page must render the saved page background") | |
| 145 | + } | |
| 120 | 146 | } |
| 121 | 147 | |
| 122 | 148 | func TestAdminCanDeleteRepositoriesAndAccounts(t *testing.T) { |
internal/web/handlers/repo.go
+17
-0
| ... | ... | @@ -364,6 +364,23 @@ func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error { |
| 364 | 364 | } |
| 365 | 365 | } |
| 366 | 366 | |
| 367 | + if c.FormValue("remove_background") == "true" { | |
| 368 | + if err := h.repoSvc.UpdateBackground(c.UserContext(), r.ID, ""); err != nil { | |
| 369 | + h.logger.Error("remove repository background failed", "repo_id", r.ID, "err", err) | |
| 370 | + h.setFlashT(c, "flash.image_save_failed") | |
| 371 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 372 | + } | |
| 373 | + } else if backgroundData, err := readImageField(c, "background_data", "background_file", maxBackgroundImageBytes); err != nil { | |
| 374 | + h.setFlashT(c, "flash.image_invalid") | |
| 375 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 376 | + } else if backgroundData != "" { | |
| 377 | + if err := h.repoSvc.UpdateBackground(c.UserContext(), r.ID, backgroundData); err != nil { | |
| 378 | + h.logger.Error("update repository background failed", "repo_id", r.ID, "err", err) | |
| 379 | + h.setFlashT(c, "flash.image_save_failed") | |
| 380 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 367 | 384 | h.setFlashT(c, "flash.repo_settings_saved") |
| 368 | 385 | return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) |
| 369 | 386 | } |
internal/web/static/css/app.css
+68
-62
| ... | ... | @@ -38,7 +38,7 @@ html[data-theme="light"] { |
| 38 | 38 | --secondary: 0 0% 95%; |
| 39 | 39 | --secondary-foreground: 0 0% 6.7%; |
| 40 | 40 | --muted: 0 0% 95%; |
| 41 | - --muted-foreground: 0 0% 45%; | |
| 41 | + --muted-foreground: 0 0% 38%; | |
| 42 | 42 | --accent: 0 0% 93%; |
| 43 | 43 | --accent-foreground: 0 0% 6.7%; |
| 44 | 44 | --destructive: 0 72% 46%; |
| ... | ... | @@ -75,7 +75,7 @@ html[data-theme="dark"] { |
| 75 | 75 | --secondary: 0 0% 13%; |
| 76 | 76 | --secondary-foreground: 0 0% 100%; |
| 77 | 77 | --muted: 0 0% 12%; |
| 78 | - --muted-foreground: 0 0% 65%; | |
| 78 | + --muted-foreground: 0 0% 70%; | |
| 79 | 79 | --accent: 0 0% 15%; |
| 80 | 80 | --accent-foreground: 0 0% 100%; |
| 81 | 81 | --destructive: 0 72% 58%; |
| ... | ... | @@ -112,7 +112,7 @@ html[data-theme="dark"] { |
| 112 | 112 | --secondary: 0 0% 13%; |
| 113 | 113 | --secondary-foreground: 0 0% 100%; |
| 114 | 114 | --muted: 0 0% 12%; |
| 115 | - --muted-foreground: 0 0% 65%; | |
| 115 | + --muted-foreground: 0 0% 70%; | |
| 116 | 116 | --accent: 0 0% 15%; |
| 117 | 117 | --accent-foreground: 0 0% 100%; |
| 118 | 118 | --destructive: 0 72% 58%; |
| ... | ... | @@ -293,6 +293,46 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 293 | 293 | display: flex; |
| 294 | 294 | flex-direction: column; |
| 295 | 295 | min-height: 100vh; |
| 296 | + position: relative; | |
| 297 | + isolation: isolate; | |
| 298 | +} | |
| 299 | + | |
| 300 | +.page-bg { | |
| 301 | + position: absolute; | |
| 302 | + inset: 0; | |
| 303 | + z-index: 0; | |
| 304 | + overflow: hidden; | |
| 305 | + pointer-events: none; | |
| 306 | + background: hsl(var(--background)); | |
| 307 | +} | |
| 308 | +.page-bg img { | |
| 309 | + position: absolute; | |
| 310 | + inset: 0 0 auto; | |
| 311 | + width: 100%; | |
| 312 | + height: clamp(28rem, 92vh, 54rem); | |
| 313 | + object-fit: cover; | |
| 314 | + object-position: center 28%; | |
| 315 | + opacity: 0.9; | |
| 316 | + filter: saturate(0.9) contrast(0.97); | |
| 317 | + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 38%, rgba(0, 0, 0, 0.62) 72%, transparent 100%); | |
| 318 | + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 38%, rgba(0, 0, 0, 0.62) 72%, transparent 100%); | |
| 319 | +} | |
| 320 | +.page-bg::after { | |
| 321 | + content: ""; | |
| 322 | + position: absolute; | |
| 323 | + inset: 0; | |
| 324 | + background: | |
| 325 | + radial-gradient(118% 86% at 50% 0%, transparent 16%, hsl(var(--background) / 0.36) 52%, hsl(var(--background) / 0.9) 86%, hsl(var(--background)) 100%), | |
| 326 | + linear-gradient(to bottom, | |
| 327 | + hsl(var(--background) / 0.5) 0%, | |
| 328 | + hsl(var(--background) / 0.6) 32%, | |
| 329 | + hsl(var(--background) / 0.88) 68%, | |
| 330 | + hsl(var(--background)) 100%); | |
| 331 | +} | |
| 332 | +.app-col > .mobile-topbar, | |
| 333 | +.app-col > .app-main { | |
| 334 | + position: relative; | |
| 335 | + z-index: 1; | |
| 296 | 336 | } |
| 297 | 337 | |
| 298 | 338 | .app-main { |
| ... | ... | @@ -503,7 +543,7 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 503 | 543 | background-color: hsl(var(--accent)); |
| 504 | 544 | } |
| 505 | 545 | .btn-outline { |
| 506 | - background-color: transparent; | |
| 546 | + background-color: hsl(var(--card)); | |
| 507 | 547 | color: hsl(var(--foreground)); |
| 508 | 548 | border-color: hsl(var(--border)); |
| 509 | 549 | } |
| ... | ... | @@ -512,7 +552,7 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 512 | 552 | color: hsl(var(--accent-foreground)); |
| 513 | 553 | } |
| 514 | 554 | .btn-ghost { |
| 515 | - background-color: transparent; | |
| 555 | + background-color: hsl(var(--background)); | |
| 516 | 556 | color: hsl(var(--muted-foreground)); |
| 517 | 557 | } |
| 518 | 558 | .btn-ghost:hover { |
| ... | ... | @@ -700,7 +740,7 @@ input[type="radio"]:focus-visible { |
| 700 | 740 | .card-footer { |
| 701 | 741 | padding: 0.65rem 0.9rem; |
| 702 | 742 | border-top: 1px solid hsl(var(--border)); |
| 703 | - background-color: hsl(var(--muted) / 0.3); | |
| 743 | + background-color: hsl(var(--muted)); | |
| 704 | 744 | } |
| 705 | 745 | |
| 706 | 746 | /* Badges */ |
| ... | ... | @@ -718,7 +758,7 @@ input[type="radio"]:focus-visible { |
| 718 | 758 | background-color: hsl(var(--secondary)); |
| 719 | 759 | } |
| 720 | 760 | .badge-outline { |
| 721 | - background-color: transparent; | |
| 761 | + background-color: hsl(var(--background)); | |
| 722 | 762 | color: hsl(var(--muted-foreground)); |
| 723 | 763 | } |
| 724 | 764 | .badge-primary { |
| ... | ... | @@ -793,9 +833,9 @@ kbd, .kbd { |
| 793 | 833 | align-items: center; |
| 794 | 834 | gap: 0.35rem; |
| 795 | 835 | padding: 0.28rem 0.65rem; |
| 796 | - border: 1px solid hsl(var(--border)); | |
| 836 | + border: 1px solid hsl(var(--border) / 0.9); | |
| 797 | 837 | border-radius: 9999px; |
| 798 | - background: hsl(var(--muted) / 0.25); | |
| 838 | + background: hsl(var(--card)); | |
| 799 | 839 | font-size: 0.75rem; |
| 800 | 840 | color: hsl(var(--muted-foreground)); |
| 801 | 841 | text-decoration: none; |
| ... | ... | @@ -839,7 +879,7 @@ kbd, .kbd { |
| 839 | 879 | padding: 0.2rem; |
| 840 | 880 | border: 1px solid hsl(var(--border)); |
| 841 | 881 | border-radius: 999px; |
| 842 | - background: hsl(var(--muted) / 0.25); | |
| 882 | + background: hsl(var(--muted)); | |
| 843 | 883 | margin-bottom: 1rem; |
| 844 | 884 | overflow-x: auto; |
| 845 | 885 | } |
| ... | ... | @@ -883,7 +923,7 @@ kbd, .kbd { |
| 883 | 923 | border-bottom: none; |
| 884 | 924 | } |
| 885 | 925 | .dense-row:hover { |
| 886 | - background-color: hsl(var(--accent) / 0.5); | |
| 926 | + background-color: hsl(var(--accent)); | |
| 887 | 927 | } |
| 888 | 928 | |
| 889 | 929 | .table-wrap { |
| ... | ... | @@ -904,7 +944,7 @@ kbd, .kbd { |
| 904 | 944 | color: hsl(var(--muted-foreground)); |
| 905 | 945 | padding: 0.5rem 0.75rem; |
| 906 | 946 | border-bottom: 1px solid hsl(var(--border)); |
| 907 | - background-color: hsl(var(--muted) / 0.35); | |
| 947 | + background-color: hsl(var(--muted)); | |
| 908 | 948 | font-size: 0.75rem; |
| 909 | 949 | } |
| 910 | 950 | .table td { |
| ... | ... | @@ -1232,7 +1272,7 @@ kbd, .kbd { |
| 1232 | 1272 | font-size: 0.84em; |
| 1233 | 1273 | } |
| 1234 | 1274 | .markdown-body pre { |
| 1235 | - background-color: hsl(var(--muted) / 0.6); | |
| 1275 | + background-color: hsl(var(--muted)); | |
| 1236 | 1276 | border: 1px solid hsl(var(--border)); |
| 1237 | 1277 | border-radius: var(--radius); |
| 1238 | 1278 | padding: 0.75rem 0.9rem; |
| ... | ... | @@ -1437,7 +1477,7 @@ kbd, .kbd { |
| 1437 | 1477 | gap: 0.35rem; |
| 1438 | 1478 | border: 1px solid hsl(var(--border)); |
| 1439 | 1479 | border-radius: var(--radius); |
| 1440 | - background: hsl(var(--muted) / 0.25); | |
| 1480 | + background: hsl(var(--muted)); | |
| 1441 | 1481 | padding: 0.15rem 0.25rem 0.15rem 0.6rem; |
| 1442 | 1482 | min-width: 0; |
| 1443 | 1483 | } |
| ... | ... | @@ -1458,7 +1498,7 @@ kbd, .kbd { |
| 1458 | 1498 | padding: 1.6rem 1rem; |
| 1459 | 1499 | border: 1.5px dashed hsl(var(--border)); |
| 1460 | 1500 | border-radius: calc(var(--radius) * 1.3); |
| 1461 | - background: hsl(var(--muted) / 0.2); | |
| 1501 | + background: hsl(var(--muted)); | |
| 1462 | 1502 | color: hsl(var(--muted-foreground)); |
| 1463 | 1503 | cursor: pointer; |
| 1464 | 1504 | transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; |
| ... | ... | @@ -1469,7 +1509,7 @@ kbd, .kbd { |
| 1469 | 1509 | } |
| 1470 | 1510 | .upload-drop.drag { |
| 1471 | 1511 | border-color: hsl(var(--foreground) / 0.6); |
| 1472 | - background: hsl(var(--muted) / 0.5); | |
| 1512 | + background: hsl(var(--accent)); | |
| 1473 | 1513 | color: hsl(var(--foreground)); |
| 1474 | 1514 | } |
| 1475 | 1515 | /* Utility completion (classes used in templates but absent from the build) */ |
| ... | ... | @@ -1479,52 +1519,21 @@ kbd, .kbd { |
| 1479 | 1519 | .mb-1\.5 { margin-bottom: 0.375rem; } |
| 1480 | 1520 | .pt-1 { padding-top: 0.25rem; } |
| 1481 | 1521 | |
| 1482 | -/* Profile vignette cover */ | |
| 1522 | +/* Profile header sits directly on the page backdrop. */ | |
| 1483 | 1523 | .profile-hero { |
| 1484 | 1524 | position: relative; |
| 1485 | - overflow: hidden; | |
| 1486 | - isolation: isolate; | |
| 1487 | - padding: 1.4rem 1.45rem; | |
| 1488 | - border: 1px solid hsl(var(--border)); | |
| 1489 | - border-radius: calc(var(--radius) * 1.7); | |
| 1490 | - background: hsl(var(--card)); | |
| 1491 | -} | |
| 1492 | -.profile-hero.has-bg { | |
| 1493 | - border-color: hsl(var(--border) / 0.7); | |
| 1494 | - min-height: 12.5rem; | |
| 1495 | -} | |
| 1496 | -.profile-hero-bg { | |
| 1497 | - position: absolute; | |
| 1498 | - inset: 0; | |
| 1499 | - z-index: -2; | |
| 1500 | - pointer-events: none; | |
| 1501 | -} | |
| 1502 | -.profile-hero-bg img { | |
| 1503 | - width: 100%; | |
| 1504 | - height: 100%; | |
| 1505 | - object-fit: cover; | |
| 1506 | - object-position: center 32%; | |
| 1507 | - opacity: 0.92; | |
| 1508 | - -webkit-mask-image: radial-gradient(120% 150% at 50% 22%, #000 34%, rgba(0,0,0,0.9) 55%, transparent 100%); | |
| 1509 | - mask-image: radial-gradient(120% 150% at 50% 22%, #000 34%, rgba(0,0,0,0.9) 55%, transparent 100%); | |
| 1510 | -} | |
| 1511 | -.profile-hero.has-bg::after { | |
| 1512 | - content: ""; | |
| 1513 | - position: absolute; | |
| 1514 | - inset: 0; | |
| 1515 | - z-index: -1; | |
| 1516 | - pointer-events: none; | |
| 1517 | - background: | |
| 1518 | - linear-gradient(to top, hsl(var(--background)) 2%, hsl(var(--background) / 0.84) 44%, hsl(var(--background) / 0.18) 100%), | |
| 1519 | - radial-gradient(90% 90% at 12% 30%, hsl(var(--background) / 0.38), transparent 62%); | |
| 1525 | + padding: 0.6rem 0 0; | |
| 1526 | + border: 0; | |
| 1527 | + border-radius: 0; | |
| 1528 | + background: transparent; | |
| 1520 | 1529 | } |
| 1521 | 1530 | .profile-hero-content { |
| 1522 | 1531 | position: relative; |
| 1523 | - z-index: 1; | |
| 1524 | 1532 | } |
| 1525 | -.profile-hero.has-bg .avatar-xxl { | |
| 1526 | - box-shadow: 0 0 0 4px hsl(var(--background) / 0.88), 0 0 0 5px hsl(var(--border)); | |
| 1527 | - backdrop-filter: blur(2px); | |
| 1533 | +.has-page-bg .profile-hero h1, | |
| 1534 | +.has-page-bg .profile-hero p, | |
| 1535 | +.has-page-bg .profile-hero .text-muted { | |
| 1536 | + text-shadow: 0 1px 16px hsl(var(--background) / 0.95); | |
| 1528 | 1537 | } |
| 1529 | 1538 | |
| 1530 | 1539 | /* Media settings */ |
| ... | ... | @@ -1577,13 +1586,13 @@ kbd, .kbd { |
| 1577 | 1586 | padding: 0.75rem; |
| 1578 | 1587 | border: 1px solid hsl(var(--border)); |
| 1579 | 1588 | border-radius: calc(var(--radius) * 1.2); |
| 1580 | - background: hsl(var(--muted) / 0.18); | |
| 1589 | + background: hsl(var(--card)); | |
| 1581 | 1590 | cursor: pointer; |
| 1582 | 1591 | transition: border-color 0.15s ease, background-color 0.15s ease; |
| 1583 | 1592 | } |
| 1584 | 1593 | .registration-option:hover { |
| 1585 | 1594 | border-color: hsl(var(--foreground) / 0.28); |
| 1586 | - background: hsl(var(--muted) / 0.32); | |
| 1595 | + background: hsl(var(--accent)); | |
| 1587 | 1596 | } |
| 1588 | 1597 | .registration-option:has(input:checked) { |
| 1589 | 1598 | border-color: hsl(var(--foreground) / 0.72); |
| ... | ... | @@ -1646,10 +1655,7 @@ kbd, .kbd { |
| 1646 | 1655 | grid-template-columns: 1fr; |
| 1647 | 1656 | } |
| 1648 | 1657 | .profile-hero { |
| 1649 | - padding: 1.05rem; | |
| 1650 | - } | |
| 1651 | - .profile-hero.has-bg { | |
| 1652 | - min-height: 10.8rem; | |
| 1658 | + padding: 0.35rem 0 0; | |
| 1653 | 1659 | } |
| 1654 | 1660 | .background-preview { |
| 1655 | 1661 | aspect-ratio: 16 / 7; |
internal/web/static/js/app.js
+34
-0
| ... | ... | @@ -532,6 +532,22 @@ |
| 532 | 532 | }); |
| 533 | 533 | return; |
| 534 | 534 | } |
| 535 | + if (e.target.id === 'repo-background-file-input') { | |
| 536 | + var repoBgFile = e.target.files && e.target.files[0]; | |
| 537 | + if (!repoBgFile) return; | |
| 538 | + resizeCoverImage(repoBgFile, 1600, 700, function (dataUrl) { | |
| 539 | + var hidden = document.getElementById('repo-background-data-input'); | |
| 540 | + var remove = document.getElementById('remove-repo-background-input'); | |
| 541 | + var preview = document.getElementById('repo-background-preview'); | |
| 542 | + if (hidden) hidden.value = dataUrl; | |
| 543 | + if (remove) remove.value = 'false'; | |
| 544 | + if (preview) { | |
| 545 | + preview.classList.remove('is-empty'); | |
| 546 | + preview.innerHTML = '<img src="' + dataUrl + '" alt="">'; | |
| 547 | + } | |
| 548 | + }); | |
| 549 | + return; | |
| 550 | + } | |
| 535 | 551 | if (e.target.id === 'repo-avatar-file-input') { |
| 536 | 552 | var repoFile = e.target.files && e.target.files[0]; |
| 537 | 553 | if (!repoFile) return; |
| ... | ... | @@ -567,6 +583,24 @@ |
| 567 | 583 | return; |
| 568 | 584 | } |
| 569 | 585 | |
| 586 | + var removeRepoBg = e.target.closest('#remove-repo-background-btn'); | |
| 587 | + if (removeRepoBg) { | |
| 588 | + e.preventDefault(); | |
| 589 | + var hidden = document.getElementById('repo-background-data-input'); | |
| 590 | + var remove = document.getElementById('remove-repo-background-input'); | |
| 591 | + var fileInput = document.getElementById('repo-background-file-input'); | |
| 592 | + var preview = document.getElementById('repo-background-preview'); | |
| 593 | + if (hidden) hidden.value = ''; | |
| 594 | + if (remove) remove.value = 'true'; | |
| 595 | + if (fileInput) fileInput.value = ''; | |
| 596 | + if (preview) { | |
| 597 | + preview.classList.add('is-empty'); | |
| 598 | + preview.innerHTML = '<span class="background-preview-placeholder"><svg class="icon size-5" aria-hidden="true"><use href="#hi-image"></use></svg></span>'; | |
| 599 | + } | |
| 600 | + removeRepoBg.style.display = 'none'; | |
| 601 | + return; | |
| 602 | + } | |
| 603 | + | |
| 570 | 604 | var removeRepoAvatar = e.target.closest('#remove-repo-avatar-btn'); |
| 571 | 605 | if (removeRepoAvatar) { |
| 572 | 606 | e.preventDefault(); |
internal/web/views/layouts/base.html
+8
-1
| ... | ... | @@ -25,6 +25,9 @@ |
| 25 | 25 | </head> |
| 26 | 26 | <body> |
| 27 | 27 | {{sprite}} |
| 28 | + {{$pageBackground := ""}} | |
| 29 | + {{with .ProfileUser}}{{$pageBackground = imageURL .BackgroundURL}}{{end}} | |
| 30 | + {{with .Repo}}{{$pageBackground = imageURL .BackgroundURL}}{{end}} | |
| 28 | 31 | |
| 29 | 32 | <!-- Floating button to expand sidebar when collapsed --> |
| 30 | 33 | <button type="button" class="sidebar-expand-btn" data-toggle-sidebar title="{{t .Lang "nav.toggle_sidebar"}} (Ctrl+B)" aria-label="{{t .Lang "nav.toggle_sidebar"}}"> |
| ... | ... | @@ -135,7 +138,11 @@ |
| 135 | 138 | </aside> |
| 136 | 139 | |
| 137 | 140 | <!-- Main Content Column --> |
| 138 | - <div class="app-col"> | |
| 141 | + <div class="app-col{{if $pageBackground}} has-page-bg{{end}}"> | |
| 142 | + {{if $pageBackground}} | |
| 143 | + <div class="page-bg" aria-hidden="true"><img src="{{$pageBackground}}" alt=""></div> | |
| 144 | + {{end}} | |
| 145 | + | |
| 139 | 146 | <!-- Mobile Topbar (only visible on small screens) --> |
| 140 | 147 | <div class="mobile-topbar"> |
| 141 | 148 | <div class="flex items-center gap-2"> |
internal/web/views/pages/profile.html
+1
-3
| ... | ... | @@ -1,9 +1,7 @@ |
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="container"> |
| 3 | 3 | <!-- Profile --> |
| 4 | - {{$bg := imageURL .ProfileUser.BackgroundURL}} | |
| 5 | - <section class="profile-hero mb-4{{if $bg}} has-bg{{end}}"> | |
| 6 | - {{if $bg}}<div class="profile-hero-bg" aria-hidden="true"><img src="{{$bg}}" alt=""></div>{{end}} | |
| 4 | + <section class="profile-hero mb-4"> | |
| 7 | 5 | <div class="profile-hero-content flex items-start flex-wrap"> |
| 8 | 6 | {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xxl"}} |
| 9 | 7 | <div class="flex-1 min-w-0" style="margin-left:1.6rem;min-width:15rem;"> |
internal/web/views/pages/repo_settings.html
+32
-0
| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | {{define "content"}} |
| 2 | +{{$repoBg := imageURL .Repo.BackgroundURL}} | |
| 2 | 3 | <div class="container"> |
| 3 | 4 | {{template "repo_header" .}} |
| 4 | 5 | |
| ... | ... | @@ -11,6 +12,8 @@ |
| 11 | 12 | <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> |
| 12 | 13 | <input type="hidden" id="repo-avatar-data-input" name="avatar_data" value=""> |
| 13 | 14 | <input type="hidden" id="remove-repo-avatar-input" name="remove_avatar" value="false"> |
| 15 | + <input type="hidden" id="repo-background-data-input" name="background_data" value=""> | |
| 16 | + <input type="hidden" id="remove-repo-background-input" name="remove_background" value="false"> | |
| 14 | 17 | |
| 15 | 18 | <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));"> |
| 16 | 19 | <div id="repo-avatar-preview-wrap"> |
| ... | ... | @@ -35,6 +38,35 @@ |
| 35 | 38 | </div> |
| 36 | 39 | </div> |
| 37 | 40 | |
| 41 | + <div class="flex flex-col gap-2 pb-3" style="border-bottom:1px solid hsl(var(--border));"> | |
| 42 | + <div class="flex items-center justify-between gap-2 flex-wrap"> | |
| 43 | + <div> | |
| 44 | + <div class="text-sm font-medium">{{t .Lang "repo.background"}}</div> | |
| 45 | + <div class="field-hint" style="margin:0;">{{t .Lang "repo.background_hint"}}</div> | |
| 46 | + </div> | |
| 47 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 48 | + <label class="btn btn-outline btn-sm" style="cursor:pointer;"> | |
| 49 | + {{icon "image" "size-3.5"}} | |
| 50 | + <span>{{t .Lang "repo.background_upload"}}</span> | |
| 51 | + <input id="repo-background-file-input" name="background_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;"> | |
| 52 | + </label> | |
| 53 | + {{if $repoBg}} | |
| 54 | + <button type="button" id="remove-repo-background-btn" class="btn btn-ghost btn-sm text-destructive"> | |
| 55 | + {{icon "trash" "size-3.5"}} | |
| 56 | + <span>{{t .Lang "repo.background_remove"}}</span> | |
| 57 | + </button> | |
| 58 | + {{end}} | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + <div id="repo-background-preview" class="background-preview{{if not $repoBg}} is-empty{{end}}"> | |
| 62 | + {{if $repoBg}} | |
| 63 | + <img src="{{$repoBg}}" alt=""> | |
| 64 | + {{else}} | |
| 65 | + <span class="background-preview-placeholder">{{icon "image" "size-5"}}</span> | |
| 66 | + {{end}} | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + | |
| 38 | 70 | <div> |
| 39 | 71 | <label class="label" for="description">{{t .Lang "repo.description"}}</label> |
| 40 | 72 | <input id="description" name="description" type="text" class="input" value="{{.Repo.Description}}" maxlength="500" placeholder="{{t .Lang "repo.description_ph"}}"> |