diff --git a/apps/web/src/lib/components/layout/Layout.svelte b/apps/web/src/lib/components/layout/Layout.svelte
index 88c6226e0..ff192f35a 100644
--- a/apps/web/src/lib/components/layout/Layout.svelte
+++ b/apps/web/src/lib/components/layout/Layout.svelte
@@ -7,6 +7,7 @@ import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { getPatrons } from "#lib/features/front-page/front-page.remote.ts";
import { getNotifications } from "#lib/features/notifications/notifications.remote.ts";
import { toNotificationRows } from "#lib/features/notifications/notifications-utils.ts";
+import { setSidenavCollapsed } from "#lib/features/sidenav/sidenav.remote.ts";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import { GIT_COMMIT } from "#lib/utils/git-commit.ts";
@@ -100,13 +101,14 @@ function unseenRequestsLabel(count: number) {
: m.friends_unseenRequests_other({ count });
}
-function toggleSideNavCollapsed() {
+const sideNavCollapseForm = setSidenavCollapsed.enhance(async ({ submit }) => {
sideNavCollapsed = !sideNavCollapsed;
- void fetch("/sidenav", {
- method: "POST",
- body: new URLSearchParams({ collapsed: String(sideNavCollapsed) }),
- });
-}
+ try {
+ await submit().updates();
+ } catch {
+ // losing the preference cookie is fine, the optimistic state stays
+ }
+});
// mobile hide-on-scroll header, ported from the React useNavOffset
const MOBILE_BREAKPOINT = 600;
@@ -389,13 +391,17 @@ function formatRelativeDate(timestamp: number) {
class="sideNavCollapseButtonContainer"
data-testid="sidenav-collapse-button"
>
-
+
{#if sideNavCollapsed && showUnseenDot}
{/if}
@@ -663,6 +669,10 @@ function formatRelativeDate(timestamp: number) {
}
}
+ .sideNavCollapseForm {
+ display: contents;
+ }
+
.collapseButton {
display: none;
align-items: center;
diff --git a/apps/web/src/lib/features/sidenav/sidenav.remote.ts b/apps/web/src/lib/features/sidenav/sidenav.remote.ts
new file mode 100644
index 000000000..e54f94756
--- /dev/null
+++ b/apps/web/src/lib/features/sidenav/sidenav.remote.ts
@@ -0,0 +1,11 @@
+import * as v from "valibot";
+import { form, getRequestEvent } from "$app/server";
+import { writeSidenavCollapsed } from "./sidenav.server.ts";
+
+/** Persists whether the desktop sidenav is collapsed. */
+export const setSidenavCollapsed = form(
+ v.object({ collapsed: v.optional(v.boolean(), false) }),
+ async ({ collapsed }) => {
+ writeSidenavCollapsed(getRequestEvent().cookies, collapsed);
+ },
+);
diff --git a/apps/web/src/routes/sidenav/+server.ts b/apps/web/src/lib/features/sidenav/sidenav.server.ts
similarity index 59%
rename from apps/web/src/routes/sidenav/+server.ts
rename to apps/web/src/lib/features/sidenav/sidenav.server.ts
index c2c08907e..ca1340fbb 100644
--- a/apps/web/src/routes/sidenav/+server.ts
+++ b/apps/web/src/lib/features/sidenav/sidenav.server.ts
@@ -1,19 +1,21 @@
-import { json } from "@sveltejs/kit";
+import type { Cookies } from "@sveltejs/kit";
import {
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
import { ServerConfig } from "#lib/server/config.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
-import type { RequestHandler } from "./$types";
const SIDENAV_COOKIE_NAME = "sidenav";
const TEN_YEARS_IN_SECONDS = 315_360_000;
-export const POST: RequestHandler = async ({ request, cookies }) => {
- const form = new URLSearchParams(await request.text());
- const collapsed = form.get("collapsed") === "true";
+/** Reads the desktop sidenav collapsed preference from its cookie. */
+export function readSidenavCollapsed(cookies: Cookies): boolean {
+ return readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME))?.collapsed === true;
+}
+/** Persists the desktop sidenav collapsed preference into its cookie. */
+export function writeSidenavCollapsed(cookies: Cookies, collapsed: boolean) {
const session = readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME)) ?? {};
session.collapsed = collapsed;
cookies.set(SIDENAV_COOKIE_NAME, writeSessionCookie(session), {
@@ -23,6 +25,4 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
httpOnly: true,
maxAge: TEN_YEARS_IN_SECONDS,
});
-
- return json({ success: true });
-};
+}
diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts
index 2fcb82e81..1d7a9fcfe 100644
--- a/apps/web/src/routes/+layout.server.ts
+++ b/apps/web/src/routes/+layout.server.ts
@@ -1,15 +1,11 @@
-import { readSessionCookie } from "#lib/features/auth/session.server.ts";
import { resolveSidebarData } from "#lib/features/sidebar/sidebar.server.ts";
+import { readSidenavCollapsed } from "#lib/features/sidenav/sidenav.server.ts";
import { getTheme } from "#lib/features/theme/theme.server.ts";
import type { LayoutServerLoad } from "./$types";
-const SIDENAV_COOKIE_NAME = "sidenav";
-
export const load: LayoutServerLoad = async ({ locals, cookies }) => {
const user = locals.user;
- const sidenavSession = readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME));
-
return {
user: user
? {
@@ -28,7 +24,7 @@ export const load: LayoutServerLoad = async ({ locals, cookies }) => {
createdAt: user.createdAt,
}
: undefined,
- sidenavCollapsed: sidenavSession?.collapsed === true,
+ sidenavCollapsed: readSidenavCollapsed(cookies),
sidebar: await resolveSidebarData(user?.id ?? null),
theme: getTheme(cookies),
};