✨ (settings): Add per-user content width setting
Changes
12 files changed, +298 -14
MODIFY
README.md
+1 -0
@@ -30,6 +30,7 @@
30
30
- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
31
31
- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique across users and organisations). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. The code-flow callback is pinned to `/login` (`redirect-path` + `restore-path-after-redirect`, so strict-`redirect_uri` IdPs like kanidm register one URI) and after login the user lands back on the page they came from — the header's Log in button carries `?redirect=<current page>` (local paths only, open-redirect guarded). Expired ID tokens are refreshed silently with the refresh token (`refresh-expired=true`, 60 s proactive skew, session cookie usable 12 h past expiry) instead of logging the user out. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
32
32
- Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
33
+- Per-user content width setting (Appearance section on `/settings/profile`): **Full** (default — repository pages span the entire screen, other pages cap at 1400px), **Comfortable (fixed 1400px)**, or **Compact (fixed 1120px)** on every page; the width also aligns the header nav content (the bar background stays edge-to-edge), and windows narrower than the cap just fill the screen. Guide: [profile settings](docs/users/profile.md)
33
34
- **Collaborators** — the repository owner can grant other local users read+write access
34
35
(one flat role) on a per-repository settings page (`…/settings/collaborators`, reached from a
35
36
"Manage collaborators" link on the owner-only repository Settings page). Collaborators can read and push — UI, HTTP, and SSH alike — even
MODIFY
docs/users/profile.md
+24 -0
@@ -17,6 +17,30 @@
17
17
18
18
---
19
19
20
+## Appearance
21
+
22
+The **Appearance** section controls how wide pages are rendered for you. Pick
23
+one of three **Content width** presets and press **Save**:
24
+
25
+| Preset | Width | Good for |
26
+|---|---|---|
27
+| **Full** (default) | Repository pages (`/repos/<owner>/<name>/…`) span the entire screen; all other pages cap at a comfortable 1400px | Diffs, code browsing, wide tables |
28
+| **Comfortable** | Fixed 1400px column on every page, centered | Balanced reading width |
29
+| **Compact** | Fixed 1120px column on every page, centered | Focused reading on large monitors |
30
+
31
+Comfortable and Compact are uniform across the whole app: every page uses the
32
+same fixed pixel column, so the width doesn't shift around when you resize the
33
+window or navigate. Full is uniform per page type instead — repository pages
34
+get the entire screen (diffs and file trees benefit most), everything else the
35
+centered 1400px column. Whatever width the current page uses, the header bar's
36
+content (logo, navigation, account menu) aligns with the same column while the
37
+bar's background still spans the full screen; on windows narrower than the cap
38
+the content simply fills the screen. The setting applies as soon as you save
39
+and sticks across sessions (it's stored on your account, not in the browser).
40
+Visitors who aren't logged in always get the Full layout.
41
+
42
+---
43
+
20
44
## Profile picture
21
45
22
46
The **Profile picture** section lets you upload an avatar:
ADD
src/main/java/de/workaround/account/AppearanceService.java
+37 -0
@@ -0,0 +1,37 @@
1
+package de.workaround.account;
2
+
3
+import java.util.Locale;
4
+
5
+import de.workaround.model.User;
6
+import jakarta.enterprise.context.ApplicationScoped;
7
+import jakarta.inject.Inject;
8
+import jakarta.transaction.Transactional;
9
+
10
+/**
11
+ * Persists per-user display preferences chosen on the settings page. Currently only the content
12
+ * width preset, which scales the layout's main column (see {@link User.ContentWidth}).
13
+ */
14
+@ApplicationScoped
15
+public class AppearanceService
16
+{
17
+ @Inject
18
+ User.Repo users;
19
+
20
+ @Transactional
21
+ public void setContentWidth(User user, String preset)
22
+ {
23
+ User.ContentWidth parsed;
24
+ try
25
+ {
26
+ parsed = User.ContentWidth.valueOf(preset == null ? "" : preset.trim().toUpperCase(Locale.ROOT));
27
+ }
28
+ catch (IllegalArgumentException e)
29
+ {
30
+ throw new InvalidContentWidthException("Unknown content width preset.");
31
+ }
32
+ User managed = users.findById(user.id);
33
+ managed.contentWidth = parsed;
34
+ user.contentWidth = parsed;
35
+ }
36
+
37
+}
MODIFY
src/main/java/de/workaround/account/CurrentUser.java
+10 -0
@@ -43,6 +43,16 @@
43
43
return cached;
44
44
}
45
45
46
+ /**
47
+ * CSS class for the user's content-width preset, applied on {@code <body>} by the layout so
48
+ * header nav and main column share one width. Empty for anonymous visitors and the FULL default.
49
+ */
50
+ public String contentWidthClass()
51
+ {
52
+ User user = get();
53
+ return user == null ? "" : user.contentWidth.cssClass;
54
+ }
55
+
46
56
public User require()
47
57
{
48
58
User user = get();
ADD
src/main/java/de/workaround/account/InvalidContentWidthException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.account;
2
+
3
+public class InvalidContentWidthException extends RuntimeException
4
+{
5
+ public InvalidContentWidthException(String message)
6
+ {
7
+ super(message);
8
+ }
9
+
10
+}
MODIFY
src/main/java/de/workaround/account/SettingsResource.java
+30 -5
@@ -14,6 +14,7 @@
14
14
import de.workaround.http.AccessTokenService;
15
15
import de.workaround.model.AccessToken;
16
16
import de.workaround.model.SshKey;
17
+import de.workaround.model.User;
17
18
import io.quarkus.qute.CheckedTemplate;
18
19
import io.quarkus.qute.TemplateInstance;
19
20
import jakarta.inject.Inject;
@@ -41,7 +42,7 @@
41
42
static native TemplateInstance tokenCreated(String plaintext);
42
43
43
44
static native TemplateInstance profile(String username, String displayName, boolean hasAvatar,
44
- Instant avatarUpdatedAt, String error);
45
+ Instant avatarUpdatedAt, User.ContentWidth contentWidth, String error);
45
46
}
46
47
47
48
@Inject
@@ -59,12 +60,16 @@
59
60
@Inject
60
61
AvatarService avatars;
61
62
63
+ @Inject
64
+ AppearanceService appearance;
65
+
62
66
@GET
63
67
@Path("/profile")
64
68
public TemplateInstance profile()
65
69
{
66
70
de.workaround.model.User user = currentUser.require();
67
- return Templates.profile(user.username, user.displayName, user.hasAvatar(), user.avatarUpdatedAt, null);
71
+ return Templates.profile(user.username, user.displayName, user.hasAvatar(), user.avatarUpdatedAt,
72
+ user.contentWidth, null);
68
73
}
69
74
70
75
@POST
@@ -83,7 +88,27 @@
83
88
{
84
89
return Response.status(Response.Status.BAD_REQUEST)
85
90
.entity(Templates.profile(username, displayName, user.hasAvatar(), user.avatarUpdatedAt,
86
- e.getMessage()))
91
+ user.contentWidth, e.getMessage()))
92
+ .build();
93
+ }
94
+ }
95
+
96
+ @POST
97
+ @Path("/profile/appearance")
98
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
99
+ public Response updateAppearance(@FormParam("contentWidth") String contentWidth)
100
+ {
101
+ de.workaround.model.User user = currentUser.require();
102
+ try
103
+ {
104
+ appearance.setContentWidth(user, contentWidth);
105
+ return Response.seeOther(URI.create("/settings/profile")).build();
106
+ }
107
+ catch (InvalidContentWidthException e)
108
+ {
109
+ return Response.status(Response.Status.BAD_REQUEST)
110
+ .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
111
+ user.avatarUpdatedAt, user.contentWidth, e.getMessage()))
87
112
.build();
88
113
}
89
114
}
@@ -98,7 +123,7 @@
98
123
{
99
124
return Response.status(Response.Status.BAD_REQUEST)
100
125
.entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
101
- user.avatarUpdatedAt, "No image was uploaded."))
126
+ user.avatarUpdatedAt, user.contentWidth, "No image was uploaded."))
102
127
.build();
103
128
}
104
129
try
@@ -110,7 +135,7 @@
110
135
{
111
136
return Response.status(Response.Status.BAD_REQUEST)
112
137
.entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
113
- user.avatarUpdatedAt, e.getMessage()))
138
+ user.avatarUpdatedAt, user.contentWidth, e.getMessage()))
114
139
.build();
115
140
}
116
141
catch (IOException e)
MODIFY
src/main/java/de/workaround/model/User.java
+20 -0
@@ -9,6 +9,8 @@
9
9
import io.quarkus.hibernate.panache.PanacheEntity;
10
10
import io.quarkus.hibernate.panache.PanacheRepository;
11
11
import jakarta.persistence.Entity;
12
+import jakarta.persistence.EnumType;
13
+import jakarta.persistence.Enumerated;
12
14
import jakarta.persistence.GeneratedValue;
13
15
import jakarta.persistence.GenerationType;
14
16
import jakarta.persistence.Id;
@@ -42,11 +44,29 @@
42
44
43
45
public Instant avatarUpdatedAt;
44
46
47
+ // Display preference for the main content column; presets scale the layout max-width.
48
+ @Enumerated(EnumType.STRING)
49
+ public ContentWidth contentWidth = ContentWidth.FULL;
50
+
45
51
public boolean hasAvatar()
46
52
{
47
53
return avatarContentType != null;
48
54
}
49
55
56
+ public enum ContentWidth
57
+ {
58
+ FULL(""),
59
+ COMFORTABLE("width-comfortable"),
60
+ COMPACT("width-compact");
61
+
62
+ public final String cssClass;
63
+
64
+ ContentWidth(String cssClass)
65
+ {
66
+ this.cssClass = cssClass;
67
+ }
68
+ }
69
+
50
70
public interface Repo extends PanacheRepository.Managed<User, UUID>
51
71
{
52
72
@Find
MODIFY
src/main/resources/META-INF/resources/shark.css
+24 -8
@@ -94,6 +94,14 @@
94
94
}
95
95
96
96
body {
97
+ /* Per-user content width preset (settings > profile > appearance): one shared width for the
98
+ header nav content and the page's main column. --preset-w is what the user picked (Full =
99
+ the whole screen; the other presets are fixed px caps so the column stays put while the
100
+ window is resized — narrower windows simply fill the viewport). Non-repo pages additionally
101
+ cap at a comfortable 1400px; repo pages (the :has override below) get the raw preset width,
102
+ so Full there means the entire screen for diffs and file trees. */
103
+ --preset-w: 100%;
104
+ --content-w: min(var(--preset-w), 1400px);
97
105
margin: 0;
98
106
background: var(--canvas);
99
107
color: var(--ink);
@@ -101,6 +109,18 @@
101
109
-webkit-font-smoothing: antialiased;
102
110
}
103
111
112
+body:has(.repo-layout) {
113
+ --content-w: var(--preset-w);
114
+}
115
+
116
+body.width-comfortable {
117
+ --preset-w: 1400px;
118
+}
119
+
120
+body.width-compact {
121
+ --preset-w: 1120px;
122
+}
123
+
104
124
h1, h2, h3 {
105
125
color: var(--ink);
106
126
line-height: 1.2;
@@ -160,7 +180,9 @@
160
180
display: flex;
161
181
align-items: center;
162
182
gap: var(--s4);
163
- padding: var(--s3) var(--s5);
183
+ /* bar background spans the viewport, its content aligns with the preset content column;
184
+ max() keeps the normal gutter when the window is narrower than the preset cap */
185
+ padding: var(--s3) max(var(--s5), calc((100% - var(--content-w)) / 2 + var(--s5)));
164
186
background: var(--surface);
165
187
border-bottom: 1px solid var(--border);
166
188
}
@@ -302,17 +324,11 @@
302
324
}
303
325
304
326
main {
305
- max-width: 1120px;
327
+ max-width: var(--content-w);
306
328
margin: var(--s6) auto;
307
329
padding: 0 var(--s5);
308
330
}
309
331
310
-/* repo pages use the full viewport width: sidebar pinned far left, content fills the rest */
311
-main:has(.repo-layout) {
312
- max-width: none;
313
- margin: var(--s6) 0;
314
-}
315
-
316
332
/* in-app repo hero */
317
333
318
334
.hero {
ADD
src/main/resources/db/migration/V15__user_content_width.sql
+5 -0
@@ -0,0 +1,5 @@
1
+-- Per-user display preference for the main content column width. Presets scale the
2
+-- layout's max-width: FULL keeps the current 1120px, COMFORTABLE 85% (952px),
3
+-- COMPACT 70% (784px). Existing and new users default to FULL (no visual change).
4
+ALTER TABLE users
5
+ ADD COLUMN content_width varchar(16) NOT NULL DEFAULT 'FULL';
MODIFY
src/main/resources/templates/SettingsResource/profile.html
+14 -0
@@ -12,6 +12,20 @@
12
12
<button class="btn btn-primary">Save</button>
13
13
</form>
14
14
15
+<h2>Appearance</h2>
16
+<form method="post" action="/settings/profile/appearance">
17
+ <p><label>Content width
18
+ <select name="contentWidth">
19
+ <option value="FULL"{#if contentWidth.name == 'FULL'} selected{/if}>Full (default)</option>
20
+ <option value="COMFORTABLE"{#if contentWidth.name == 'COMFORTABLE'} selected{/if}>Comfortable (1400 px)</option>
21
+ <option value="COMPACT"{#if contentWidth.name == 'COMPACT'} selected{/if}>Compact (1120 px)</option>
22
+ </select></label></p>
23
+ <p class="muted">How wide the app is on large screens. Full spans repository pages across the whole
24
+ screen and caps other pages at 1400 px; Comfortable and Compact center a fixed-width column
25
+ on every page, header included.</p>
26
+ <button class="btn btn-primary">Save</button>
27
+</form>
28
+
15
29
<h2>Profile picture</h2>
16
30
{#if hasAvatar}
17
31
<img class="avatar-preview" src="/users/{username}/avatar?v={avatarUpdatedAt.toEpochMilli}" alt="Current profile picture">
MODIFY
src/main/resources/templates/layout.html
+1 -1
@@ -8,7 +8,7 @@
8
8
<link rel="stylesheet" href="/shark.css">
9
9
<script defer src="/shark-hotkeys.js"></script>
10
10
</head>
11
-<body>
11
+<body class="{cdi:currentUser.contentWidthClass}">
12
12
<header class="site">
13
13
<a class="brand" href="/"><svg class="bmark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#168b85"></rect><path d="M12 42 H52 M24 42 C29 42 31 50 38 50" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"></path><circle cx="16" cy="42" r="3.2" fill="#fff"></circle><circle cx="38" cy="50" r="3.2" fill="#fff"></circle><path d="M26 42 C28 32 32 24 39 14 C40 22 42 33 45 42 Z" fill="#fff"></path></svg><span class="g">git</span><span class="s">shark</span></a>
14
14
<nav>
ADD
src/test/java/de/workaround/web/ContentWidthTest.java
+122 -0
@@ -0,0 +1,122 @@
1
+package de.workaround.web;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import de.workaround.model.User;
6
+import io.quarkus.test.junit.QuarkusTest;
7
+import io.quarkus.test.security.TestSecurity;
8
+import jakarta.inject.Inject;
9
+import jakarta.transaction.Transactional;
10
+
11
+import static io.restassured.RestAssured.given;
12
+import static org.hamcrest.CoreMatchers.containsString;
13
+import static org.hamcrest.CoreMatchers.not;
14
+
15
+@QuarkusTest
16
+class ContentWidthTest
17
+{
18
+ @Test
19
+ @TestSecurity(user = "width-default-user")
20
+ void profileOffersContentWidthPresetsWithFullAsDefault() throws Exception
21
+ {
22
+ persistUser("width-default-user");
23
+
24
+ given()
25
+ .when().get("/settings/profile")
26
+ .then()
27
+ .statusCode(200)
28
+ .body(containsString("name=\"contentWidth\""))
29
+ .body(containsString("value=\"FULL\" selected"))
30
+ .body(containsString("value=\"COMFORTABLE\""))
31
+ .body(containsString("value=\"COMPACT\""));
32
+ }
33
+
34
+ @Test
35
+ @TestSecurity(user = "width-compact-user")
36
+ void savedContentWidthAppliesOnEveryPage() throws Exception
37
+ {
38
+ persistUser("width-compact-user");
39
+
40
+ given().redirects().follow(false)
41
+ .contentType("application/x-www-form-urlencoded")
42
+ .formParam("contentWidth", "COMPACT")
43
+ .when().post("/settings/profile/appearance")
44
+ .then()
45
+ .statusCode(303);
46
+
47
+ given()
48
+ .when().get("/explore")
49
+ .then()
50
+ .statusCode(200)
51
+ // the preset class sits on <body> so header nav and content scale together
52
+ .body(containsString("<body class=\"width-compact\">"));
53
+
54
+ given()
55
+ .when().get("/settings/profile")
56
+ .then()
57
+ .statusCode(200)
58
+ .body(containsString("value=\"COMPACT\" selected"));
59
+ }
60
+
61
+ @Test
62
+ void anonymousVisitorsKeepTheFullWidthDefault() throws Exception
63
+ {
64
+ given()
65
+ .when().get("/explore")
66
+ .then()
67
+ .statusCode(200)
68
+ .body(containsString("<body class=\"\">"))
69
+ .body(not(containsString("width-compact")));
70
+ }
71
+
72
+ @Test
73
+ @TestSecurity(user = "width-bogus-user")
74
+ void unknownPresetIsRejected() throws Exception
75
+ {
76
+ persistUser("width-bogus-user");
77
+
78
+ given()
79
+ .contentType("application/x-www-form-urlencoded")
80
+ .formParam("contentWidth", "BOGUS")
81
+ .when().post("/settings/profile/appearance")
82
+ .then()
83
+ .statusCode(400);
84
+ }
85
+
86
+ @Test
87
+ void stylesheetDefinesTheWidthPresets() throws Exception
88
+ {
89
+ given()
90
+ .when().get("/shark.css")
91
+ .then()
92
+ .statusCode(200)
93
+ // presets live on <body> and drive one shared width variable for header and main;
94
+ // fixed px caps so the column doesn't shift while resizing the window
95
+ .body(containsString("body.width-comfortable"))
96
+ .body(containsString("body.width-compact"))
97
+ .body(containsString("--content-w"))
98
+ .body(containsString("1400px"))
99
+ .body(containsString("1120px"))
100
+ // repo pages escape the default 1400px cap and use the full preset width
101
+ .body(containsString("body:has(.repo-layout)"));
102
+ }
103
+
104
+ @Transactional
105
+ User persistUser(String name)
106
+ {
107
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
108
+ if (existing != null)
109
+ {
110
+ return existing;
111
+ }
112
+ User user = new User();
113
+ user.oidcSub = name;
114
+ user.username = name;
115
+ user.persist();
116
+ return user;
117
+ }
118
+
119
+ @Inject
120
+ User.Repo userRepo;
121
+
122
+}