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