rin
Публичный Open-source lightweight git hosting.

feat: add repository backgrounds and solid page surfaces

R rin · 2026-10-17 06:45 UTC
10 файлов изменено +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"}}">