From 1b7582bb152cf5a48d03f8799588582518a31873 Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
Date: Sun, 12 Apr 2026 06:30:40 +0000
Subject: fix(ui): balance homepage media panels

---
 src/routes/+page.svelte | 282 +++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 220 insertions(+), 62 deletions(-)

(limited to 'src/routes/+page.svelte')

diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 268e9713..ad3971d8 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,5 +1,6 @@
 <script lang="ts">
 import type { Component } from "svelte";
+import { browser } from "$app/environment";
 import Spacer from "$lib/Layout/Spacer.svelte";
 import { onDestroy, onMount } from "svelte";
 import userIdentity from "$stores/identity.js";
@@ -41,6 +42,86 @@ let IndexColumnComponent: IndexColumnSvelteComponent | null = $state(null);
 let MangaListTemplateComponent: MangaListTemplateSvelteComponent | null =
 	$state(null);
 let authenticatedHomeSurfaceImport: Promise<void> | null = null;
+let balancedListFlowElement: HTMLDivElement | undefined = $state();
+let balancedListResizeObserver: ResizeObserver | undefined;
+let balancedListMeasurementFrame = 0;
+const balancedListPanelElements = new Map<string, HTMLElement>();
+let balancedListPanelAssignments = $state<Record<string, "left" | "right">>({});
+
+const resetBalancedListLayout = () => {
+	balancedListPanelAssignments = {};
+};
+
+const updateBalancedListLayout = () => {
+	if (!balancedListFlowElement) return;
+
+	const balancedListFlowStyles = getComputedStyle(balancedListFlowElement);
+	const balancedListRowGap = parseFloat(
+		balancedListFlowStyles.getPropertyValue("row-gap"),
+	);
+	const balancedListColumnCount = balancedListFlowStyles
+		.getPropertyValue("grid-template-columns")
+		.split(" ")
+		.filter(Boolean).length;
+
+	if (balancedListColumnCount <= 1 || !Number.isFinite(balancedListRowGap)) {
+		balancedListPanelAssignments = Object.fromEntries(
+			Array.from(balancedListPanelElements.keys()).map((key) => [key, "left"]),
+		);
+
+		return;
+	}
+
+	const balancedListKeys = ["upcoming", "due", "manga"].filter((key) =>
+		balancedListPanelElements.has(key),
+	);
+	const balancedListHeights = { left: 0, right: 0 };
+	const nextBalancedListAssignments: Record<string, "left" | "right"> = {};
+
+	balancedListKeys.forEach((key) => {
+		const balancedListPanel = balancedListPanelElements.get(key);
+		if (!balancedListPanel) return;
+
+		const balancedListColumn =
+			balancedListHeights.left <= balancedListHeights.right ? "left" : "right";
+		const balancedListPanelHeight =
+			balancedListPanel.getBoundingClientRect().height;
+
+		nextBalancedListAssignments[key] = balancedListColumn;
+		balancedListHeights[balancedListColumn] +=
+			balancedListPanelHeight +
+			(balancedListHeights[balancedListColumn] > 0 ? balancedListRowGap : 0);
+	});
+
+	balancedListPanelAssignments = nextBalancedListAssignments;
+};
+
+const queueBalancedListLayout = () => {
+	if (!browser) return;
+
+	cancelAnimationFrame(balancedListMeasurementFrame);
+
+	balancedListMeasurementFrame = requestAnimationFrame(() => {
+		updateBalancedListLayout();
+	});
+};
+
+const observeBalancedListPanel = (panel: HTMLElement, key: string) => {
+	balancedListPanelElements.set(key, panel);
+	balancedListResizeObserver?.observe(panel);
+	queueBalancedListLayout();
+
+	return {
+		destroy: () => {
+			balancedListPanelElements.delete(key);
+			balancedListResizeObserver?.unobserve(panel);
+			queueBalancedListLayout();
+		},
+	};
+};
+
+const isBalancedListPanelInColumn = (key: string, column: "left" | "right") =>
+	(balancedListPanelAssignments[key] ?? "left") === column;
 
 const loadAuthenticatedHomeSurface = () => {
 	if (data.user === undefined) return null;
@@ -68,10 +149,30 @@ onMount(async () => {
 	await hydrateStateBin();
 	$stateBin.upcomingAnimeListOpen ??= true;
 	$stateBin.dueMangaListOpen ??= true;
+
+	balancedListResizeObserver = new ResizeObserver(() => {
+		queueBalancedListLayout();
+	});
+
+	balancedListPanelElements.forEach((panel) => {
+		balancedListResizeObserver?.observe(panel);
+	});
+
+	window.addEventListener("resize", queueBalancedListLayout);
 	void loadAuthenticatedHomeSurface();
+
+	queueBalancedListLayout();
 });
 
-onDestroy(() => removeHeightObserver?.());
+onDestroy(() => {
+	removeHeightObserver?.();
+	balancedListResizeObserver?.disconnect();
+	if (browser) {
+		cancelAnimationFrame(balancedListMeasurementFrame);
+		window.removeEventListener("resize", queueBalancedListLayout);
+	}
+	resetBalancedListLayout();
+});
 </script>
 
 <HeadTitle />
@@ -87,85 +188,140 @@ onDestroy(() => removeHeightObserver?.());
 
   <Landing />
 {:else}
+  {@const balancedListColumnCount =
+    [!$settings.disableUpcomingAnime, !$settings.disableAnime, !$settings.disableManga]
+      .map(Number)
+      .reduce((a, b) => a + b) > 1
+      ? 2
+      : 1}
   <div
-    class="grid-container"
+    bind:this={balancedListFlowElement}
+    class="balanced-list-flow"
     style={`
-			grid-template-columns: ${
-        [!$settings.disableUpcomingAnime, !$settings.disableAnime, !$settings.disableManga]
-          .map(Number)
-          .reduce((a, b) => a + b) > 1
-          ? '1fr 1fr'
-          : '1fr'
-      }
+			--balanced-list-columns: ${balancedListColumnCount}
 		`}
   >
-    <div class="left-column">
-      {#if !$settings.disableUpcomingAnime}
-        <details bind:open={$stateBin.upcomingAnimeListOpen} class="list list-upcoming">
-          {#if $userIdentity.id !== -2}
-            {#if UpcomingAnimeListComponent}
-              <UpcomingAnimeListComponent user={data.user} />
+    <div class="balanced-list-column">
+      {#if !$settings.disableUpcomingAnime && isBalancedListPanelInColumn("upcoming", "left")}
+        <div class="balanced-list-panel" use:observeBalancedListPanel={"upcoming"}>
+          <details bind:open={$stateBin.upcomingAnimeListOpen} class="list list-upcoming">
+            {#if $userIdentity.id !== -2}
+              {#if UpcomingAnimeListComponent}
+                <UpcomingAnimeListComponent user={data.user} />
+              {:else}
+                <ListTitle title={$locale().lists.upcoming.episodes} />
+
+                <Skeleton card={false} count={5} height="0.9rem" list />
+              {/if}
             {:else}
               <ListTitle title={$locale().lists.upcoming.episodes} />
 
               <Skeleton card={false} count={5} height="0.9rem" list />
             {/if}
+          </details>
+        </div>
+      {/if}
+
+      {#if !$settings.disableAnime && isBalancedListPanelInColumn("due", "left")}
+        <div class="balanced-list-panel" use:observeBalancedListPanel={"due"}>
+          {#if IndexColumnComponent}
+            <IndexColumnComponent user={data.user} userIdentity={$userIdentity} />
           {:else}
-            <ListTitle title={$locale().lists.upcoming.episodes} />
+            <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due">
+              <ListTitle title={$locale().lists.due.episodes} />
 
-            <Skeleton card={false} count={5} height="0.9rem" list />
+              <Skeleton card={false} count={5} height="0.9rem" list />
+            </details>
           {/if}
-        </details>
+        </div>
       {/if}
 
-      {#if !$settings.disableAnime && !$settings.disableManga}
-        {#if IndexColumnComponent}
-          <IndexColumnComponent user={data.user} userIdentity={$userIdentity} />
-        {:else}
-          <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due">
-            <ListTitle title={$locale().lists.due.episodes} />
+      {#if !$settings.disableManga && isBalancedListPanelInColumn("manga", "left")}
+        <div class="balanced-list-panel" use:observeBalancedListPanel={"manga"}>
+          <details bind:open={$stateBin.dueMangaListOpen} class="list list-manga">
+            {#if $userIdentity.id !== -2}
+              {#if MangaListTemplateComponent}
+                <MangaListTemplateComponent
+                  user={data.user}
+                  displayUnresolved={$settings.displayUnresolved}
+                  due={true}
+                />
+              {:else}
+                <ListTitle title={$locale().lists.due.mangaAndLightNovels} />
 
-            <Skeleton card={false} count={5} height="0.9rem" list />
+                <Skeleton card={false} count={5} height="0.9rem" list />
+              {/if}
+            {:else}
+              <ListTitle title={$locale().lists.due.mangaAndLightNovels} />
+
+              <Skeleton card={false} count={5} height="0.9rem" list />
+            {/if}
           </details>
-        {/if}
+        </div>
       {/if}
     </div>
 
-    <div class="right-column">
-      {#if !$settings.disableAnime && $settings.disableManga}
-        {#if IndexColumnComponent}
-          <IndexColumnComponent user={data.user} userIdentity={$userIdentity} />
-        {:else}
-          <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due">
-            <ListTitle title={$locale().lists.due.episodes} />
+    {#if balancedListColumnCount > 1}
+      <div class="balanced-list-column">
+        {#if !$settings.disableUpcomingAnime && isBalancedListPanelInColumn("upcoming", "right")}
+          <div class="balanced-list-panel" use:observeBalancedListPanel={"upcoming"}>
+            <details bind:open={$stateBin.upcomingAnimeListOpen} class="list list-upcoming">
+              {#if $userIdentity.id !== -2}
+                {#if UpcomingAnimeListComponent}
+                  <UpcomingAnimeListComponent user={data.user} />
+                {:else}
+                  <ListTitle title={$locale().lists.upcoming.episodes} />
 
-            <Skeleton card={false} count={5} height="0.9rem" list />
-          </details>
+                  <Skeleton card={false} count={5} height="0.9rem" list />
+                {/if}
+              {:else}
+                <ListTitle title={$locale().lists.upcoming.episodes} />
+
+                <Skeleton card={false} count={5} height="0.9rem" list />
+              {/if}
+            </details>
+          </div>
         {/if}
-      {/if}
 
-      {#if !$settings.disableManga}
-        <details bind:open={$stateBin.dueMangaListOpen} class="list list-manga">
-          {#if $userIdentity.id !== -2}
-            {#if MangaListTemplateComponent}
-              <MangaListTemplateComponent
-                user={data.user}
-                displayUnresolved={$settings.displayUnresolved}
-                due={true}
-              />
+        {#if !$settings.disableAnime && isBalancedListPanelInColumn("due", "right")}
+          <div class="balanced-list-panel" use:observeBalancedListPanel={"due"}>
+            {#if IndexColumnComponent}
+              <IndexColumnComponent user={data.user} userIdentity={$userIdentity} />
             {:else}
-              <ListTitle title={$locale().lists.due.mangaAndLightNovels} />
+              <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due">
+                <ListTitle title={$locale().lists.due.episodes} />
 
-              <Skeleton card={false} count={5} height="0.9rem" list />
+                <Skeleton card={false} count={5} height="0.9rem" list />
+              </details>
             {/if}
-          {:else}
-            <ListTitle title={$locale().lists.due.mangaAndLightNovels} />
+          </div>
+        {/if}
 
-            <Skeleton card={false} count={5} height="0.9rem" list />
-          {/if}
-        </details>
-      {/if}
-    </div>
+        {#if !$settings.disableManga && isBalancedListPanelInColumn("manga", "right")}
+          <div class="balanced-list-panel" use:observeBalancedListPanel={"manga"}>
+            <details bind:open={$stateBin.dueMangaListOpen} class="list list-manga">
+              {#if $userIdentity.id !== -2}
+                {#if MangaListTemplateComponent}
+                  <MangaListTemplateComponent
+                    user={data.user}
+                    displayUnresolved={$settings.displayUnresolved}
+                    due={true}
+                  />
+                {:else}
+                  <ListTitle title={$locale().lists.due.mangaAndLightNovels} />
+
+                  <Skeleton card={false} count={5} height="0.9rem" list />
+                {/if}
+              {:else}
+                <ListTitle title={$locale().lists.due.mangaAndLightNovels} />
+
+                <Skeleton card={false} count={5} height="0.9rem" list />
+              {/if}
+            </details>
+          </div>
+        {/if}
+      </div>
+    {/if}
 
     {#if $settings.disableUpcomingAnime && $settings.disableAnime && $settings.disableManga}
       <video src="https://video.twimg.com/tweet_video/Do_eDPnX0AAKV9f.mp4" autoplay loop>
@@ -176,30 +332,32 @@ onDestroy(() => removeHeightObserver?.());
 {/if}
 
 <style>
-  .grid-container {
+  .balanced-list-flow {
     display: grid;
+    grid-template-columns: repeat(var(--balanced-list-columns), minmax(0, 1fr));
     gap: 1rem;
+    align-items: start;
   }
 
-  .left-column {
+  .balanced-list-column {
     display: grid;
     gap: 1rem;
     align-content: start;
+    min-width: 0;
   }
 
-  .right-column {
-    align-self: start;
+  .balanced-list-panel {
+    min-width: 0;
   }
 
   .list {
     overflow-y: auto;
-    break-inside: avoid;
-    page-break-inside: avoid;
+    margin: 0;
   }
 
   @media (max-width: 800px) {
-    .grid-container {
-      grid-template-columns: 1fr !important;
+    .balanced-list-flow {
+      grid-template-columns: 1fr;
     }
   }
 </style>
-- 
cgit v1.2.3

