From 7b07276f7b77251f5a136601501e1bd94c06f3b6 Mon Sep 17 00:00:00 2001
From: Sebastian Bernd Mohr <sebastian.mohr@ds.mpg.de>
Date: Mon, 10 Feb 2025 12:32:38 +0000
Subject: [PATCH] Added book pages navigation via page routing.

---
 CHANGELOG.md                                  |  10 +-
 .../books/[id]/(page_viewer)/layout.tsx       |  22 +++
 .../(page_viewer)/page_ids/[p_id]/page.tsx    |  45 ++++++
 .../[id]/(page_viewer)/pages/[no]/page.tsx    |  50 +++++++
 .../(editor_layout)/books/[id]/layout.tsx     | 138 +++++++++++++++++-
 .../(editor_layout)/books/[id]/loading.tsx    |  14 +-
 .../(editor_layout)/books/[id]/page.tsx       |  10 +-
 .../snips/[id]/components.tsx                 |   2 +-
 apps/fullstack/app/(protected)/loading.tsx    |  11 ++
 .../fullstack/app/debug/multi/[slug]/page.tsx |  25 ++++
 .../components/books/browser/FileView.tsx     |   2 +-
 apps/fullstack/components/editor/context.tsx  | 101 +++----------
 .../components/editor/editor.module.scss      |   4 +
 apps/fullstack/components/editor/index.tsx    |  44 ++++--
 .../components/editor/sidebar/index.tsx       |   6 +-
 .../sidebar/navigation/pageCreateForm.tsx     |  11 +-
 .../sidebar/navigation/pageSwitcher.tsx       |  20 +--
 .../editor/sidebar/previews/previewPages.tsx  |  43 +-----
 .../editor/sidebar/sidebar.module.scss        |   8 +-
 .../components/editor/tools/toolbar.tsx       |  14 +-
 .../components/editor/viewer/index.tsx        |   1 +
 .../components/editor/viewer/page.tsx         |  19 ++-
 .../editor/worker/pageWorker/page.ts          |   3 +-
 .../lib/hooks/usePreventSwipeGestures.ts      |   1 +
 package.json                                  |   2 +-
 packages/database/src/services/snip.ts        |  27 ++++
 packages/database/src/strategies.ts           |   7 +-
 27 files changed, 461 insertions(+), 179 deletions(-)
 create mode 100644 apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/layout.tsx
 create mode 100644 apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/page_ids/[p_id]/page.tsx
 create mode 100644 apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/pages/[no]/page.tsx
 create mode 100644 apps/fullstack/app/(protected)/loading.tsx
 create mode 100644 apps/fullstack/app/debug/multi/[slug]/page.tsx

diff --git a/CHANGELOG.md b/CHANGELOG.md
index cfeedb45..7b7ebd59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,20 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## [Unreleased]
+## [1.11.3]
 
 ## Fixed
 
 - Connection issue modal pops up if a page transition is triggered while the connection is lost now has a 5s delay before showing the modal
 
+## Added
+
+- Transition between pages in the editor now has a loading indicator
+
+## Changed
+
+- Book pages are now a full page route instead of a page hash. I.e. /book/:id/page/:pageid instead of /book/:id#page=:pageid
+
 ## [1.11.2]
 
 ## Added
diff --git a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/layout.tsx b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/layout.tsx
new file mode 100644
index 00000000..35062024
--- /dev/null
+++ b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/layout.tsx
@@ -0,0 +1,22 @@
+import { EditorToolbar, EditorViewer } from "components/editor";
+import EditorResizer from "components/editor/resizer";
+
+/** Only show the viewer if a page was requested else show all pages
+ * in the book.
+ */
+export default function PageLayout({
+    children,
+}: {
+    children: React.ReactNode;
+}) {
+    return (
+        <>
+            <EditorResizer />
+            <div className="position-relative">
+                <EditorToolbar />
+                <EditorViewer />
+                {children}
+            </div>
+        </>
+    );
+}
diff --git a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/page_ids/[p_id]/page.tsx b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/page_ids/[p_id]/page.tsx
new file mode 100644
index 00000000..d26e6918
--- /dev/null
+++ b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/page_ids/[p_id]/page.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+import type { PageDataRet } from "@snip/database/types";
+
+import { usePagesContext } from "components/context/socket/usePagesContext";
+import { useEditorContext } from "components/editor/context";
+
+/** This page does nothing but handling the transition
+ * between book pages.
+ *
+ * no = page_number
+ */
+export default function Page({
+    params,
+}: {
+    params: { id: string; p_id: string };
+}) {
+    const router = useRouter();
+    const { setActivePageIds } = useEditorContext();
+    const { pages } = usePagesContext();
+
+    useEffect(() => {
+        if (
+            params.id === undefined ||
+            params.p_id === undefined ||
+            isNaN(parseInt(params.p_id))
+        ) {
+            return;
+        }
+
+        const page_id = parseInt(params.p_id);
+        const page = pages.get(page_id);
+        if (page) {
+            router.replace(
+                `/books/${params.id}/pages/${page.page_number! + 1}`,
+            );
+            setActivePageIds([page.id]);
+        }
+    }, [router, pages, params.id, params.p_id, setActivePageIds]);
+
+    return null;
+}
diff --git a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/pages/[no]/page.tsx b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/pages/[no]/page.tsx
new file mode 100644
index 00000000..384693c5
--- /dev/null
+++ b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/(page_viewer)/pages/[no]/page.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+import type { PageDataRet } from "@snip/database/types";
+
+import { usePagesContext } from "components/context/socket/usePagesContext";
+import { useEditorContext } from "components/editor/context";
+
+/** This page does nothing but handling the transition
+ * between book pages.
+ *
+ * no = page_number
+ */
+export default function Page({
+    params,
+}: {
+    params: { id: string; no: string };
+}) {
+    const router = useRouter();
+    const { setActivePageIds } = useEditorContext();
+    const { pagesArray } = usePagesContext();
+
+    useEffect(() => {
+        if (
+            params.id === undefined ||
+            params.no === undefined ||
+            isNaN(parseInt(params.no))
+        ) {
+            return;
+        }
+
+        const page_no = parseInt(params.no);
+        let page: undefined | PageDataRet;
+        if (page_no < 1) {
+            page = pagesArray.at(page_no);
+        } else {
+            page = pagesArray.at(page_no - 1);
+        }
+        if (page) {
+            router.replace(
+                `/books/${params.id}/pages/${page.page_number! + 1}`,
+            );
+            setActivePageIds([page.id]);
+        }
+    }, [router, pagesArray, params.id, params.no, setActivePageIds]);
+
+    return null;
+}
diff --git a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/layout.tsx b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/layout.tsx
index c49d54fe..69057c42 100644
--- a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/layout.tsx
+++ b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/layout.tsx
@@ -1,11 +1,143 @@
+import { getSessionAction } from "lib/session";
+import { unstable_cache } from "next/cache";
+import { notFound, redirect } from "next/navigation";
+import { Metadata } from "next/types";
+
+import { getPerms } from "@snip/auth/book_permissions";
+import { SnipSessionData } from "@snip/auth/types";
+import service from "@snip/database";
+
+import {
+    EditorContextProvider,
+    EditorSidebar,
+    EditorWrapper,
+    permsToEnabledTools,
+} from "components/editor";
+import EditorResizer from "components/editor/resizer";
+
 import "./layout.scss";
 
-/** The layout for the viewer page
+/** The layout for the editor.
+ *
+ * Attaches a context provider for this book socket and checks if a user is
+ * allowed to view this book.
  */
-export default async function RootLayout({
+export default async function EditorLayout({
+    params,
     children,
 }: {
+    params: { id: string; page_no?: string };
     children: React.ReactNode;
 }) {
-    return <div className="h-100 overflow-hidden">{children}</div>;
+    /* ----------------------------- Authentication ----------------------------- */
+
+    // Check if book id is valid in general
+    const _book_id = params.id;
+    if (!_book_id || isNaN(parseInt(_book_id))) {
+        return notFound();
+    }
+    const book_id = parseInt(_book_id);
+
+    // Check if user session is valid
+    const session = await getSessionAction();
+    const { user, anonymous } = session;
+    if (!user && (!anonymous || anonymous.tokens.length === 0)) {
+        return notFound();
+    }
+
+    // Check if user has permissions to view this book
+    const perms = await getBookPermsFromSession(book_id, session);
+    if (!perms.resolved.pRead) {
+        // Give meaingful error messages
+        for (const p of perms.all) {
+            if (
+                p.auth_method === "ui_token" &&
+                p.expires_at &&
+                p.expires_at < new Date()
+            ) {
+                return redirect("/expired");
+            }
+        }
+        return redirect("/unauthorized");
+    }
+
+    /* -------------------------------- Book Data ------------------------------- */
+
+    // Get initial book data, pages and snips
+    // TODO: in theory we could get away with just the snipids
+    // and fetch the snips on demand
+    const [bookData, pages, queuedSnips] = await Promise.all([
+        fetchBook(book_id),
+        service.page.getByBookId(book_id),
+        service.snip.getQueuedByBookId(book_id),
+    ]);
+
+    const tools = permsToEnabledTools(perms.resolved);
+
+    console.log("EditorLayout", params);
+
+    return (
+        <div className="h-100 overflow-hidden">
+            <EditorContextProvider
+                bookData={bookData}
+                pages={pages}
+                queuedSnips={queuedSnips}
+                tools={tools}
+            >
+                <EditorWrapper>
+                    <EditorSidebar init_with_width={true} />
+                    {children}
+                </EditorWrapper>
+            </EditorContextProvider>
+        </div>
+    );
+}
+
+async function getBookPermsFromSession(
+    book_id: number,
+    session: SnipSessionData,
+) {
+    const { user, anonymous } = session;
+
+    // Get valid token with this bookid
+    let potentialValid = anonymous?.tokens.filter(
+        (token) => token.book_id === book_id,
+    );
+    if (potentialValid && potentialValid.length === 0) {
+        potentialValid = undefined;
+    }
+
+    const perms = await getPerms(book_id, {
+        user_id: user?.id,
+        ui_token_id: anonymous?.tokens?.map((t) => t.id),
+    });
+
+    return perms;
+}
+
+const fetchBook = unstable_cache(async (book_id: number) => {
+    const book = await service.book.getById(book_id);
+    return book;
+});
+
+export async function generateMetadata({
+    params,
+}: {
+    params: {
+        id: string;
+    };
+}): Promise<Metadata> {
+    // read route params
+    const id = params.id;
+
+    //Get book data
+    const book = await fetchBook(parseInt(id)).catch(() => {
+        return {
+            title: "Book not found",
+        };
+    });
+
+    return {
+        title: book.title,
+    };
 }
diff --git a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/loading.tsx b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/loading.tsx
index 2c816af6..96ab866f 100644
--- a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/loading.tsx
+++ b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/loading.tsx
@@ -1,9 +1,17 @@
-import Loading from "app/(unprotected)/loading";
+import Loading from "components/utils/Loading";
 
+/** This loading applies to the pages layout
+ * and is used to show a loading spinner.
+ */
 export default function LoadingPage() {
     return (
-        <div className="d-flex h-100 w-100 justify-content-center align-items-center">
-            <div>
+        <div
+            style={{
+                height: "100%",
+                width: "calc(100% - 350px)",
+            }}
+        >
+            <div className="d-flex justify-content-center align-items-center w-100 h-100">
                 <Loading />
             </div>
         </div>
diff --git a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/page.tsx b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/page.tsx
index e846a278..31c7984c 100644
--- a/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/page.tsx
+++ b/apps/fullstack/app/(protected)/(editor_layout)/books/[id]/page.tsx
@@ -29,7 +29,7 @@ export default function Page({ params }: { params: { id: string } }) {
         let page_number: null | number = null;
         //Get all matches url#page=[any number positive or negative]
         if (hash && hash.match(/page/g)) {
-            const pages = hash
+            const sel_pages = hash
                 .match(/page=-?\d+/g)
                 ?.map((p) => parseInt(p.split("=")[1]!))
                 .sort((a, b) => a - b)
@@ -39,8 +39,8 @@ export default function Page({ params }: { params: { id: string } }) {
                 })
                 .filter((p) => p !== undefined);
 
-            if (pages) {
-                page_number = pages[0]!.page_number;
+            if (sel_pages && sel_pages.length > 0) {
+                page_number = sel_pages[0]!.page_number! + 1;
             }
         }
 
@@ -54,13 +54,15 @@ export default function Page({ params }: { params: { id: string } }) {
             if (page_ids) {
                 const page = pages.get(page_ids[0]!);
                 if (page) {
-                    page_number = page.page_number;
+                    page_number = page.page_number! + 1;
                 }
             }
         }
 
         if (page_number) {
             router.replace(`/books/${params.id}/pages/${page_number}`);
+        } else {
+            router.replace(`/books/${params.id}`);
         }
     }, [pagesArray, router, params.id, pages]);
 
diff --git a/apps/fullstack/app/(protected)/(generic_layout)/snips/[id]/components.tsx b/apps/fullstack/app/(protected)/(generic_layout)/snips/[id]/components.tsx
index 029efb61..3bcc0777 100644
--- a/apps/fullstack/app/(protected)/(generic_layout)/snips/[id]/components.tsx
+++ b/apps/fullstack/app/(protected)/(generic_layout)/snips/[id]/components.tsx
@@ -154,7 +154,7 @@ function ImagePagePreviewBySnip({
 function ShowUtils({ snip }: { snip: SnipDataRet }) {
     let href = `/books/${snip.book_id}`;
     if (snip.page_id) {
-        href += `#page_id=${snip.page_id}`;
+        href += `/page_ids/${snip.page_id}`;
     }
 
     return (
diff --git a/apps/fullstack/app/(protected)/loading.tsx b/apps/fullstack/app/(protected)/loading.tsx
new file mode 100644
index 00000000..2c816af6
--- /dev/null
+++ b/apps/fullstack/app/(protected)/loading.tsx
@@ -0,0 +1,11 @@
+import Loading from "app/(unprotected)/loading";
+
+export default function LoadingPage() {
+    return (
+        <div className="d-flex h-100 w-100 justify-content-center align-items-center">
+            <div>
+                <Loading />
+            </div>
+        </div>
+    );
+}
diff --git a/apps/fullstack/app/debug/multi/[slug]/page.tsx b/apps/fullstack/app/debug/multi/[slug]/page.tsx
new file mode 100644
index 00000000..b7996524
--- /dev/null
+++ b/apps/fullstack/app/debug/multi/[slug]/page.tsx
@@ -0,0 +1,25 @@
+"use client";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+
+import { randomAnimal } from "@snip/common/random/index";
+
+export default function Page({ params }: { params: { slug: string } }) {
+    const [state, setState] = useState(0);
+    const [nextPage, setRandomAnimal] = useState<string | null>(null);
+
+    useEffect(() => {
+        setRandomAnimal(randomAnimal());
+    }, []);
+
+    return (
+        <div style={{ padding: "1rem" }}>
+            <div>Page Slug: {params.slug}</div>
+            <div>Page State: {state}</div>
+            <button onClick={() => setState(state + 1)}>Increment</button>
+            <div>
+                <Link href={"./" + nextPage}>Go to {nextPage}</Link>
+            </div>
+        </div>
+    );
+}
diff --git a/apps/fullstack/components/books/browser/FileView.tsx b/apps/fullstack/components/books/browser/FileView.tsx
index 9ddaf2b7..6a27cf1b 100644
--- a/apps/fullstack/components/books/browser/FileView.tsx
+++ b/apps/fullstack/components/books/browser/FileView.tsx
@@ -251,7 +251,7 @@ function FileListItem({
             <div className={styles.icon}>{icon}</div>
             <div className={styles.name}>
                 {!file.isDir ? (
-                    <Link href={`/books/${file.id}#page=-1`} passHref>
+                    <Link href={`/books/${file.id}/pages/-1`} passHref>
                         {file.name}
                     </Link>
                 ) : (
diff --git a/apps/fullstack/components/editor/context.tsx b/apps/fullstack/components/editor/context.tsx
index 91c5eb20..46fead00 100644
--- a/apps/fullstack/components/editor/context.tsx
+++ b/apps/fullstack/components/editor/context.tsx
@@ -12,11 +12,16 @@ import {
     useState,
 } from "react";
 
-import type { ID, PageDataRet, UserConfigDataRet } from "@snip/database/types";
+import type {
+    BookDataRet,
+    ID,
+    PageDataRet,
+    UserConfigDataRet,
+} from "@snip/database/types";
 import { ViewPort } from "@snip/render/viewport";
 import { BaseData, BaseView, SnipData } from "@snip/snips/general/base";
 
-import { Rect } from "./tools/types";
+import { Rect, Tool } from "./tools/types";
 
 import {
     PagesContextProvider,
@@ -64,7 +69,11 @@ interface EditorContextI {
 
     // User config
     userConfig: UserConfigDataRet;
-    book_id: number;
+
+    // Book data
+    bookData: BookDataRet;
+
+    enabledTools: Tool[];
 }
 
 const EditorContext = createContext<EditorContextI | null>(null);
@@ -87,10 +96,12 @@ export function useEditorContext() {
  */
 function _EditorContextProvider({
     children,
-    book_id,
+    bookData,
+    tools,
 }: {
     children: React.ReactNode;
-    book_id: number;
+    bookData: BookDataRet;
+    tools: Tool[];
 }) {
     /** Currently shown page by its id
      */
@@ -184,97 +195,33 @@ function _EditorContextProvider({
                 scale,
                 triggerScaleZoomUpdate,
                 userConfig: userConfig!,
-                book_id,
+                bookData,
+                enabledTools: tools,
             }}
         >
-            <UrlPageHashHandler />
             {children}
         </EditorContext.Provider>
     );
 }
 
-/** Parse the #page in the url
- * and set the active page accordingly
- *
- * This only works on the initial load
- * and does not update the page on hash change
- * after the initial load.
- */
-function UrlPageHashHandler() {
-    const { activePageIds, setActivePageIds } = useEditorContext();
-    const { pages, pagesArray } = usePagesContext();
-
-    useEffect(() => {
-        const hash = window.location.hash;
-        //Get all matches url#page=[any number positive or negative]
-        if (hash && hash.match(/page/g)) {
-            const pages = hash
-                .match(/page=-?\d+/g)
-                ?.map((p) => parseInt(p.split("=")[1]!))
-                .sort((a, b) => a - b)
-                .map((p) => {
-                    if (p < 0) return pagesArray.at(p);
-                    return pagesArray.at(p - 1);
-                })
-                .filter((p) => p !== undefined);
-
-            if (pages) {
-                setActivePageIds(pages.map((p) => p.id));
-            }
-        }
-
-        // Get all page_ids in hash
-        if (hash && hash.match(/page_id/g)) {
-            const page_ids = hash
-                .match(/page_id=\d+/g)
-                ?.map((p) => parseInt(p.split("=")[1]!))
-                .filter((p) => p !== undefined);
-
-            if (page_ids) {
-                setActivePageIds(page_ids);
-            }
-        }
-    }, [pagesArray, setActivePageIds]);
-
-    useEffect(() => {
-        // Update hash on page change
-        let hash = "";
-        activePageIds.forEach((id, i) => {
-            const page = pages.get(id);
-
-            if (
-                page &&
-                page.page_number !== undefined &&
-                page.page_number !== null
-            ) {
-                hash += `page=${page.page_number + 1}`;
-            }
-            if (i !== activePageIds.length - 1) {
-                hash += "&";
-            }
-        });
-        window.location.hash = hash;
-    }, [activePageIds, pages]);
-
-    return null;
-}
-
 export function EditorContextProvider({
-    book_id,
+    bookData,
     pages,
     queuedSnips,
+    tools,
     children,
 }: {
-    book_id: number;
+    bookData: BookDataRet;
     pages: PageDataRet[];
+    tools: Tool[];
     queuedSnips: SnipData<BaseData, BaseView>[];
     children: React.ReactNode;
 }) {
     return (
-        <SocketContextProvider book_id={book_id}>
+        <SocketContextProvider book_id={bookData.id}>
             <PagesContextProvider init_pages={pages}>
                 <WorkerContextProvider n={1}>
-                    <_EditorContextProvider book_id={book_id}>
+                    <_EditorContextProvider bookData={bookData} tools={tools}>
                         <QueuedSnipsContextProvider init_snips={queuedSnips}>
                             {children}
                         </QueuedSnipsContextProvider>
diff --git a/apps/fullstack/components/editor/editor.module.scss b/apps/fullstack/components/editor/editor.module.scss
index eaf25646..db909e3b 100644
--- a/apps/fullstack/components/editor/editor.module.scss
+++ b/apps/fullstack/components/editor/editor.module.scss
@@ -21,3 +21,7 @@
         padding-bottom: env(safe-area-inset-bottom);
     }
 }
+
+.viewer {
+    position: relative;
+}
diff --git a/apps/fullstack/components/editor/index.tsx b/apps/fullstack/components/editor/index.tsx
index 5be50e65..043e371e 100644
--- a/apps/fullstack/components/editor/index.tsx
+++ b/apps/fullstack/components/editor/index.tsx
@@ -1,4 +1,3 @@
-"use client";
 import { usePreventSwipeGestures } from "lib/hooks/usePreventSwipeGestures";
 
 import type { BookDataRet, PageDataRet } from "@snip/database/types";
@@ -15,12 +14,15 @@ import styles from "./editor.module.scss";
 
 export function EditorSinglePageNoSidebar({
     pageData,
+    bookData,
 }: {
     pageData: PageDataRet;
+    bookData: BookDataRet;
 }) {
     return (
         <EditorContextProvider
-            book_id={pageData.book_id}
+            tools={["interaction", "movePage"]}
+            bookData={bookData}
             pages={[pageData]}
             queuedSnips={[]}
         >
@@ -44,28 +46,41 @@ export function EditorSinglePage({
     perms: Perms;
 }) {
     // Parse tools based on the bookData
-    const tools = getAllowedTools(perms);
+    const tools = permsToEnabledTools(perms);
     usePreventSwipeGestures();
 
     return (
         <EditorContextProvider
-            book_id={bookData.id}
+            bookData={bookData}
             pages={pages}
             queuedSnips={queuedSnips}
+            tools={tools}
         >
-            <div id="the_editor" className={styles.editor}>
-                <Sidebar bookData={bookData} init_with_width={false} />
+            <EditorWrapper>
+                <Sidebar init_with_width={false} />
                 <Resizer />
-                <div className="position-relative">
-                    <Toolbar tools={tools} />
+                <ViewerWrapper>
+                    <Toolbar />
                     <Viewer />
-                </div>
-            </div>
+                </ViewerWrapper>
+            </EditorWrapper>
         </EditorContextProvider>
     );
 }
 
-function getAllowedTools(perms: Perms) {
+function EditorWrapper({ children }: { children: React.ReactNode }) {
+    return (
+        <div id="the_editor" className={styles.editor}>
+            {children}
+        </div>
+    );
+}
+
+function ViewerWrapper({ children }: { children: React.ReactNode }) {
+    return <div className={styles.viewer}>{children}</div>;
+}
+
+export function permsToEnabledTools(perms: Perms) {
     const tools: Tool[] = ["interaction", "movePage"];
 
     if (perms.pWrite) {
@@ -77,3 +92,10 @@ function getAllowedTools(perms: Perms) {
     }
     return tools;
 }
+
+// Allow to import from the index file
+export { EditorContextProvider };
+export { EditorWrapper };
+export { Sidebar as EditorSidebar };
+export { Viewer as EditorViewer };
+export { Toolbar as EditorToolbar };
diff --git a/apps/fullstack/components/editor/sidebar/index.tsx b/apps/fullstack/components/editor/sidebar/index.tsx
index 94358e7c..36fe0d58 100644
--- a/apps/fullstack/components/editor/sidebar/index.tsx
+++ b/apps/fullstack/components/editor/sidebar/index.tsx
@@ -5,6 +5,7 @@ import Tab from "react-bootstrap/Tab";
 
 import type { BookDataRet } from "@snip/database/types";
 
+import { useEditorContext } from "../context";
 import { SidebarContextProvider } from "./context";
 import { BottomNavBar } from "./navigation/bottomNavBar";
 import PageSearch from "./navigation/pageSearch";
@@ -20,13 +21,12 @@ import styles from "./sidebar.module.scss";
  * of them in the book.
  */
 export default function Sidebar({
-    bookData,
     init_with_width,
 }: {
-    bookData: BookDataRet;
     init_with_width: boolean;
 }) {
-    // Get all page ids for the book and request rende
+    const { bookData } = useEditorContext();
+    // Get all page ids for the book and request render
     const {
         snipsArray: snips,
         notSeenSnips,
diff --git a/apps/fullstack/components/editor/sidebar/navigation/pageCreateForm.tsx b/apps/fullstack/components/editor/sidebar/navigation/pageCreateForm.tsx
index 37c9f4be..92f282c6 100644
--- a/apps/fullstack/components/editor/sidebar/navigation/pageCreateForm.tsx
+++ b/apps/fullstack/components/editor/sidebar/navigation/pageCreateForm.tsx
@@ -11,7 +11,6 @@ import React, {
     useState,
 } from "react";
 import FormCheck from "react-bootstrap/esm/FormCheck";
-import Modal, { ModalProps } from "react-bootstrap/esm/Modal";
 import useSWR from "swr";
 
 import { BackgroundTypeData } from "@snip/database/types";
@@ -46,7 +45,7 @@ export const AdvancedPageCreationContextProvider = ({
     children: React.ReactNode;
 }) => {
     // Fetch all available books
-    const { book_id } = useEditorContext();
+    const { bookData } = useEditorContext();
     const {
         data,
         isLoading: isLoadingBooks,
@@ -58,20 +57,20 @@ export const AdvancedPageCreationContextProvider = ({
         selected: selectedBackground,
         isLoading: isLoadingBackground,
         error: errorBackground,
-    } = useBackgroundType(book_id);
+    } = useBackgroundType(bookData.id);
 
     // Filter out the current book
     const [books, pages] = useMemo(() => {
         if (!data) return [[], []];
-        const b = data.books.filter((b) => b.id !== book_id);
+        const b = data.books.filter((b) => b.id !== bookData.id);
         const p = data.pages?.filter((p) => {
             const firstPage = p[0];
             if (!firstPage) return false;
-            return firstPage.book_id !== book_id;
+            return firstPage.book_id !== bookData.id;
         });
 
         return [b, p];
-    }, [data, book_id]);
+    }, [data, bookData]);
 
     return (
         <AdvancedPageCreationContext.Provider
diff --git a/apps/fullstack/components/editor/sidebar/navigation/pageSwitcher.tsx b/apps/fullstack/components/editor/sidebar/navigation/pageSwitcher.tsx
index e7677044..3072a7c4 100644
--- a/apps/fullstack/components/editor/sidebar/navigation/pageSwitcher.tsx
+++ b/apps/fullstack/components/editor/sidebar/navigation/pageSwitcher.tsx
@@ -1,5 +1,6 @@
 "use client";
 import useMobileSafeContextMenu from "lib/hooks/useMobileSafeContextMenu";
+import { useRouter } from "next/navigation";
 import { lazy, Suspense, useCallback, useEffect, useState } from "react";
 import { Button, ButtonGroup, Modal } from "react-bootstrap";
 
@@ -18,7 +19,8 @@ const AdvancedPageCreationForm = lazy(() => import("./pageCreateForm"));
 
 export default function PageSwitcher({ finished }: { finished: boolean }) {
     const { pagesArray: pages } = usePagesContext();
-    const { activePageIds, setActivePageIds } = useEditorContext();
+    const router = useRouter();
+    const { activePageIds, bookData } = useEditorContext();
 
     const [boundaries, setBoundaries] = useState<{
         first: boolean;
@@ -49,20 +51,14 @@ export default function PageSwitcher({ finished }: { finished: boolean }) {
                 .flat();
 
             const next_page_nums = currentPages.map(
-                (p) => p.page_number! + increment,
+                (p) => p.page_number! + increment + 1, //offset by 1 to show [1,n]
             );
 
-            // Get next pages
-            // only works if pages are sorted by page number
-            const nextPages = next_page_nums.map((num) => {
-                return pages[num]!;
-            });
-
-            const nextPageIds = nextPages.map((p) => p.id);
-
-            setActivePageIds(nextPageIds);
+            router.push(
+                `/books/${bookData.id}/pages/${next_page_nums.join("/")}`,
+            );
         },
-        [pages, activePageIds, setActivePageIds],
+        [activePageIds, router, bookData.id, pages],
     );
 
     /** Initial check for boundaries */
diff --git a/apps/fullstack/components/editor/sidebar/previews/previewPages.tsx b/apps/fullstack/components/editor/sidebar/previews/previewPages.tsx
index 897d1215..67b5da3c 100644
--- a/apps/fullstack/components/editor/sidebar/previews/previewPages.tsx
+++ b/apps/fullstack/components/editor/sidebar/previews/previewPages.tsx
@@ -1,5 +1,7 @@
 "use client";
 import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
 import { useEffect, useRef } from "react";
 
 import { isInViewport } from "@snip/common";
@@ -27,45 +29,12 @@ import styles from "./preview.module.scss";
  * a subset of the pages.
  */
 export function PreviewPages() {
+    const router = useRouter();
     const { pagesArray: pages } = usePagesContext();
     const { filterPages } = useSidebarContext();
-    const { activePageIds, setActivePageIds } = useEditorContext();
+    const { activePageIds, bookData } = useEditorContext();
     const { pageToOnlineUsers } = useSocketContext();
 
-    function onPreviewClick(
-        page: PageDataRet,
-        e: React.MouseEvent<HTMLDivElement, MouseEvent>,
-    ) {
-        e.preventDefault();
-
-        if (e.ctrlKey || e.shiftKey) {
-            // add page to active pages up to max 2
-            setActivePageIds((prev) => {
-                if (!prev.includes(page.id) && prev.length < 2) {
-                    prev.push(page.id);
-                } else if (prev.includes(page.id)) {
-                    prev = prev.filter((p) => p !== page.id);
-                } else if (!prev.includes(page.id)) {
-                    //pop push new
-                    prev = prev.filter((p) => p !== page.id);
-                    prev.pop();
-                    prev.unshift(page.id);
-                }
-                return [...prev];
-            });
-        } else {
-            // set page as active
-            setActivePageIds((prev) => {
-                if (prev.includes(page.id)) {
-                    return prev;
-                }
-                prev.pop();
-                prev.unshift(page.id);
-                return [...prev];
-            });
-        }
-    }
-
     return (
         <div className={styles.wrapper}>
             {pages.map((page, i) => {
@@ -77,7 +46,9 @@ export function PreviewPages() {
                             filterPages ? !filterPages.includes(page.id) : false
                         }
                         onClick={(page, e) => {
-                            onPreviewClick(page, e);
+                            router.push(
+                                `/books/${bookData?.id}/pages/${page.page_number! + 1}`,
+                            );
                         }}
                         active={activePageIds?.includes(page.id)}
                         onlineUsers={pageToOnlineUsers.get(page.id) || []}
diff --git a/apps/fullstack/components/editor/sidebar/sidebar.module.scss b/apps/fullstack/components/editor/sidebar/sidebar.module.scss
index 32a3cca7..b24f46df 100644
--- a/apps/fullstack/components/editor/sidebar/sidebar.module.scss
+++ b/apps/fullstack/components/editor/sidebar/sidebar.module.scss
@@ -18,7 +18,7 @@
     }
 
     &[data-init-width="true"] {
-        width: 340px;
+        width: 350px;
     }
     background: repeating-linear-gradient(
         45deg,
@@ -27,6 +27,12 @@
         transparent 25px
     );
     background-color: var(--bs-body-bg);
+
+    // Overwrite initial width if the sidebar is
+    // the only child
+    &:only-child {
+        width: 100% !important;
+    }
 }
 
 /** Overwrite default bootstrap styles */
diff --git a/apps/fullstack/components/editor/tools/toolbar.tsx b/apps/fullstack/components/editor/tools/toolbar.tsx
index 5ed9e4b7..04588c4b 100644
--- a/apps/fullstack/components/editor/tools/toolbar.tsx
+++ b/apps/fullstack/components/editor/tools/toolbar.tsx
@@ -30,23 +30,19 @@ import { usePagesContext } from "components/context/socket/usePagesContext";
 
 import styles from "./toolbar.module.scss";
 
-export function Toolbar({
-    tools = ["interaction", "movePage", "doodle", "text", "placement", "image"],
-}: {
-    tools?: Tool[];
-}) {
+export function Toolbar() {
     // Filter based on referenced page
 
     const { pages } = usePagesContext();
-    const { activePageIds } = useEditorContext();
+    const { activePageIds, enabledTools } = useEditorContext();
 
     const activePages = useMemo(() => {
         return activePageIds.map((id) => pages.get(id));
     }, [activePageIds, pages]);
 
     const toolsComponents = useMemo(() => {
-        return tools.map((tool) => getToolComponents(tool));
-    }, [tools]);
+        return enabledTools.map((tool) => getToolComponents(tool));
+    }, [enabledTools]);
 
     const is_referenced_page = activePages.some((p) => p?.referenced_page_id);
 
@@ -89,7 +85,7 @@ export function Toolbar({
                             the original
                         </span>{" "}
                         <Link
-                            href={`/books/${activePages[0]!.background_type_id!}#page_id=${activePages[0]!.referenced_page_id}`}
+                            href={`/books/${activePages[0]!.referenced_book_id!}/page_ids/${activePages[0]!.referenced_page_id}`}
                             target="_blank"
                         >
                             here
diff --git a/apps/fullstack/components/editor/viewer/index.tsx b/apps/fullstack/components/editor/viewer/index.tsx
index a0433985..3cc858ed 100644
--- a/apps/fullstack/components/editor/viewer/index.tsx
+++ b/apps/fullstack/components/editor/viewer/index.tsx
@@ -1,3 +1,4 @@
+"use client";
 import { Fragment } from "react";
 
 import { useEditorContext } from "../context";
diff --git a/apps/fullstack/components/editor/viewer/page.tsx b/apps/fullstack/components/editor/viewer/page.tsx
index 21c10d00..c14be4cb 100644
--- a/apps/fullstack/components/editor/viewer/page.tsx
+++ b/apps/fullstack/components/editor/viewer/page.tsx
@@ -4,6 +4,7 @@ import React, {
     forwardRef,
     useEffect,
     useRef,
+    useState,
 } from "react";
 
 import type { PageDataRet } from "@snip/database/types";
@@ -14,6 +15,7 @@ import { OverlayWorker } from "../worker/overlayWorker";
 import { PageWorker } from "../worker/pageWorker";
 
 import { useWorker } from "components/context/viewer/useWorkerContext";
+import Loading from "components/utils/Loading";
 
 import styles from "./viewer.module.scss";
 
@@ -23,6 +25,7 @@ type PageProps = {
 } & React.HTMLAttributes<HTMLDivElement>;
 
 export function Page({ idx = 0, pageData, ...props }: PageProps) {
+    const [transitioning, setTransitioning] = useState(false);
     /** We register each page to the editor if
      * it exists.
      */
@@ -50,10 +53,11 @@ export function Page({ idx = 0, pageData, ...props }: PageProps) {
      */
     useEffect(() => {
         if (!worker || !overlayWorker || !pageData) return;
+        setTransitioning(true);
         Promise.all([
             worker.setupPage(pageData),
             overlayWorker.setupPage(pageData),
-        ]).then(() => {
+        ]).then(async () => {
             if (firstMount.current) {
                 const rect = editorElementRef.current
                     .get(idx)!
@@ -66,7 +70,8 @@ export function Page({ idx = 0, pageData, ...props }: PageProps) {
                 firstMount.current = false;
                 triggerScaleZoomUpdate(idx, worker.viewport);
             }
-            worker.rerender();
+            await worker.rerender();
+            setTransitioning(false);
         });
     }, [
         worker,
@@ -89,6 +94,16 @@ export function Page({ idx = 0, pageData, ...props }: PageProps) {
             className={styles.pageWrapper}
             {...props}
         >
+            {transitioning && (
+                <Loading
+                    style={{
+                        position: "absolute",
+                        justifyContent: "center",
+                        height: "100%",
+                        backdropFilter: "blur(2px)",
+                    }}
+                />
+            )}
             <RemotePointers overlayWorker={overlayWorker} />
         </Canvases>
     );
diff --git a/apps/fullstack/components/editor/worker/pageWorker/page.ts b/apps/fullstack/components/editor/worker/pageWorker/page.ts
index 73086161..d8a12be1 100644
--- a/apps/fullstack/components/editor/worker/pageWorker/page.ts
+++ b/apps/fullstack/components/editor/worker/pageWorker/page.ts
@@ -14,7 +14,6 @@ import {
 import { ClientToServerEvents, ServerToClientEvents } from "@snip/socket/types";
 
 import { getSocket } from "components/context/socket";
-// So we have our interface from your sample code
 
 interface PageEvents {
     inserted: [newSnip: BaseSnip];
@@ -30,7 +29,7 @@ export class Page extends LocalPage {
      * The idea is to e.g. subscribe to the change event and
      * rerender the page.
      */
-    public events = new EventEmitter<PageEvents>();
+    public readonly events = new EventEmitter<PageEvents>();
 
     async init(): Promise<void> {
         this.socket = await getSocket(this.data.book_id);
diff --git a/apps/fullstack/lib/hooks/usePreventSwipeGestures.ts b/apps/fullstack/lib/hooks/usePreventSwipeGestures.ts
index 769c3173..5da4b414 100644
--- a/apps/fullstack/lib/hooks/usePreventSwipeGestures.ts
+++ b/apps/fullstack/lib/hooks/usePreventSwipeGestures.ts
@@ -1,3 +1,4 @@
+"use client";
 import { useEffect } from "react";
 
 /**
diff --git a/package.json b/package.json
index 832f3cba..d7c55004 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
     "name": "snip",
-    "version": "1.11.2",
+    "version": "1.11.3",
     "description": "our digital lab book",
     "author": "Sebastian B. Mohr, Markus Osterhoff",
     "repository": {
diff --git a/packages/database/src/services/snip.ts b/packages/database/src/services/snip.ts
index 5d84fca6..f12c8888 100644
--- a/packages/database/src/services/snip.ts
+++ b/packages/database/src/services/snip.ts
@@ -302,6 +302,33 @@ export class SnipService implements SnipStrategy {
         return snips;
     }
 
+    /**
+     * Retrieves queued snip ids by book ID. I.e. in not placed on a page yet
+     *
+     * @param book_id - The ID of the book.
+     * @param show_hidden - Optional parameter to include hidden snips. Default is false.
+     * @returns A promise that resolves to an array of snip ids.
+     */
+    async getQueuedIdsByBookId(
+        book_id: ID,
+        show_hidden = false,
+    ): Promise<ID[]> {
+        let sql =
+            "SELECT id FROM `snips` WHERE `book_id` = ? AND `page_id` IS NULL";
+        if (!show_hidden) {
+            sql += " AND `hide` = 0";
+        }
+
+        const snipids = await pool
+            .query(sql, [book_id])
+            .then(check_mariadb_warning)
+            .then((res) => {
+                return res.id;
+            });
+
+        return snipids;
+    }
+
     /**
      * Updates a snip with the provided data.
      *
diff --git a/packages/database/src/strategies.ts b/packages/database/src/strategies.ts
index 844e3985..cfa50711 100644
--- a/packages/database/src/strategies.ts
+++ b/packages/database/src/strategies.ts
@@ -2,11 +2,9 @@ import {
     BackgroundTypeData,
     BlobData,
     BookDataInsert,
-    BookDataResolved,
     BookDataRet,
     BookOwner,
     CredentialsData,
-    Entity,
     FileData,
     FolderDataInsert,
     FolderDataRet,
@@ -18,10 +16,6 @@ import {
     MemberRole,
     PageDataInsert,
     PageDataRet,
-    PartialBy,
-    PermissionACLData,
-    PermissionACLDataResolved,
-    Resource,
     SnipDataInsert,
     SnipDataRet,
     UserConfigDataInsert,
@@ -160,6 +154,7 @@ export interface SnipStrategy
     extends TableStrategy<SnipDataInsert, SnipDataRet> {
     getByPageId: (page_id: number) => Promise<SnipDataRet[]>;
     getQueuedByBookId: (book_id: number) => Promise<SnipDataRet[]>;
+    getQueuedIdsByBookId: (book_id: number) => Promise<number[]>;
     placeOnPage: (snip_id: number, page_id: number) => Promise<SnipDataRet>;
     deleteNested: (snip_id: ID) => Promise<boolean>;
 }
-- 
GitLab