diff --git a/app/app/main/explore/_shared/page-content.tsx b/app/app/main/explore/_shared/page-content.tsx new file mode 100644 index 0000000..f61ed57 --- /dev/null +++ b/app/app/main/explore/_shared/page-content.tsx @@ -0,0 +1,107 @@ +import { getCityAtom } from "@/utils/bookmarks"; +import { MapMarker, MarkerType, useMapStore } from "@/utils/store"; +import { NodeType, RootNode } from "@bcye/structured-wikivoyage-types"; +import { UseQueryResult } from "@tanstack/react-query"; +import { Link, Route } from "expo-router"; +import { useAtomValue } from "jotai/react"; +import { filter, map, split, splitEvery } from "ramda"; +import { useEffect } from "react"; +import { Card, View } from "react-native-ui-lib"; + +/** + * Shared page content component that displays section cards and registers bookmark markers. + */ +export function PageContent({ + pageQuery, + id, + basePath, +}: { + pageQuery: UseQueryResult; + id: string; + basePath: string; +}) { + const bookmarks = useAtomValue(getCityAtom(id)); + const registerMarker = useMapStore((s) => s.registerMarker); + const deregisterMarker = useMapStore((s) => s.deregisterMarker); + + useEffect( + function registerBookmarks() { + const markers: MapMarker[] = []; + for (const [bId, bookmark] of Object.entries(bookmarks)) { + const [lat, long] = map(parseFloat, split(",", bId)); + const marker: MapMarker = { + id: bId, + link: `${basePath}/${id}/section/${bookmark.section}` as Route, + lat, + long, + type: MarkerType.Bookmark, + }; + markers.push(marker); + registerMarker(marker); + } + + return () => { + for (const marker of markers) { + deregisterMarker(marker); + } + }; + }, + [bookmarks, id, registerMarker, deregisterMarker, basePath], + ); + + return map( + ([item1, item2]) => ( + + + {item2 && ( + + )} + + ), + splitEvery( + 2, + filter((c) => c.type === NodeType.Section, pageQuery.data!.children), + ), + ); +} + +/** + * Renders a clickable infocard that links to a specific page section. + */ +function Infocard({ + title, + pageId, + basePath, +}: { + title: string; + pageId: string; + basePath: string; +}) { + return ( + + + + + + ); +} diff --git a/app/app/main/explore/_shared/scroll-container.tsx b/app/app/main/explore/_shared/scroll-container.tsx new file mode 100644 index 0000000..0c9ced0 --- /dev/null +++ b/app/app/main/explore/_shared/scroll-container.tsx @@ -0,0 +1,26 @@ +import { useIsFullscreen } from "@/hooks/use-is-fullscreen"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { ReactNode } from "react"; +import { ScrollView } from "react-native"; + +/** + * Shared scroll container that uses BottomSheetScrollView when not fullscreen, + * and regular ScrollView when fullscreen. + */ +export function ScrollContainer({ + children, + scrollRef, +}: { + children: ReactNode; + scrollRef?: React.Ref; +}) { + const isFullscreen = useIsFullscreen(); + + if (isFullscreen) { + return {children}; + } + + return ( + {children} + ); +} diff --git a/app/app/main/explore/_shared/use-section-bookmarks.ts b/app/app/main/explore/_shared/use-section-bookmarks.ts new file mode 100644 index 0000000..180cdc7 --- /dev/null +++ b/app/app/main/explore/_shared/use-section-bookmarks.ts @@ -0,0 +1,65 @@ +import { citiesAtom, getCityAtom } from "@/utils/bookmarks"; +import { SectionNode } from "@bcye/structured-wikivoyage-types"; +import { useAtom } from "jotai/react"; +import { append, assoc, dissoc } from "ramda"; +import { useCallback } from "react"; +import { toast } from "sonner-native"; + +/** + * Shared hook for managing section bookmarks + */ +export function useSectionBookmarks( + pageId: string, + title: string | string[], + section: SectionNode | undefined, + pageTitle: string | undefined, +) { + const [bookmarks, setBookmarks] = useAtom(getCityAtom(pageId)); + const [cities, setCities] = useAtom(citiesAtom); + + const isBookmarked = useCallback( + function isBookmarked(id: string) { + return !!bookmarks[id]; + }, + [bookmarks], + ); + + const toggleBookmarked = useCallback( + function toggleBookmarked(id: string) { + if (id == ",") { + toast.error( + "This listing doesn't have a location and can't be bookmarked. Add one on en.wikivoyage.org", + ); + return; + } + + if (isBookmarked(id)) { + setBookmarks(dissoc(id, bookmarks)); + } else { + if (!cities.find((c) => c.qid === pageId)) { + setCities(append({ qid: pageId, name: pageTitle! }, cities)); + } + setBookmarks( + assoc( + id, + { section: title, properties: section!.properties }, + bookmarks, + ), + ); + } + }, + [ + bookmarks, + setBookmarks, + section, + isBookmarked, + pageTitle, + cities, + setCities, + pageId, + title, + ], + ); + + return { isBookmarked, toggleBookmarked }; +} diff --git a/app/app/main/explore/page-fullscreen/[pageId]/_layout.tsx b/app/app/main/explore/page-fullscreen/[pageId]/_layout.tsx new file mode 100644 index 0000000..a9dbc3a --- /dev/null +++ b/app/app/main/explore/page-fullscreen/[pageId]/_layout.tsx @@ -0,0 +1,17 @@ +import { FullScreenProvider } from "@/hooks/use-is-fullscreen"; +import { ScrollRefProvider } from "@/hooks/use-scroll-ref"; +import { Stack } from "expo-router"; + +/** + * Layout for fullscreen page view (no map). + * Used for pages that don't have geographic coordinates. + */ +export default function FullscreenPageLayout() { + return ( + + + + + + ); +} diff --git a/app/app/main/explore/page-fullscreen/[pageId]/index.tsx b/app/app/main/explore/page-fullscreen/[pageId]/index.tsx new file mode 100644 index 0000000..54449b8 --- /dev/null +++ b/app/app/main/explore/page-fullscreen/[pageId]/index.tsx @@ -0,0 +1,54 @@ +import useWikiQuery from "@/hooks/use-wiki-query"; +import { RootNode } from "@bcye/structured-wikivoyage-types"; +import { UseQueryResult } from "@tanstack/react-query"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { PageContent } from "../../_shared/page-content"; +import { ScrollContainer } from "../../_shared/scroll-container"; +import { SkeletonView, Text, View } from "react-native-ui-lib"; + +/** + * Fullscreen page view for pages without geographic data. + */ +export default function FullscreenPage() { + let { pageId } = useLocalSearchParams(); + pageId = typeof pageId === "string" ? pageId : pageId[0]; + const pageQuery = useWikiQuery(pageId); + + return ; +} + +function FullscreenPageRootView({ + pageQuery, + id, +}: { + pageQuery: UseQueryResult; + id: string | null; +}) { + return ( + + + + pageQuery.error ? ( + + A network error occured and the place information could not be + loaded. + + ) : pageQuery.data && id ? ( + + + + ) : null + } + /> + + ); +} diff --git a/app/app/main/explore/page-fullscreen/[pageId]/section/[title].tsx b/app/app/main/explore/page-fullscreen/[pageId]/section/[title].tsx new file mode 100644 index 0000000..789494a --- /dev/null +++ b/app/app/main/explore/page-fullscreen/[pageId]/section/[title].tsx @@ -0,0 +1,50 @@ +import WikiContent from "@/components/render-node"; +import { useScrollRef } from "@/hooks/use-scroll-ref"; +import useWikiQuery from "@/hooks/use-wiki-query"; +import { NodeType, SectionNode } from "@bcye/structured-wikivoyage-types"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { ScrollContainer } from "../../../_shared/scroll-container"; +import { useSectionBookmarks } from "../../../_shared/use-section-bookmarks"; +import { SkeletonView, View } from "react-native-ui-lib"; + +/** + * Renders a specific section of a Wikipedia page in fullscreen mode. + */ +export default function FullscreenSection() { + const { title, pageId } = useLocalSearchParams(); + const wikiQuery = useWikiQuery(pageId as string); + const section = wikiQuery.data?.children.find( + (c) => c.type === NodeType.Section && c.properties.title === title, + ) as SectionNode | undefined; + const ref = useScrollRef(); + const pageTitle = wikiQuery.data?.properties.title; + + const { isBookmarked, toggleBookmarked } = useSectionBookmarks( + pageId as string, + title, + section, + pageTitle, + ); + + if (!section) return null; + + return ( + + + ( + + + + )} + /> + + ); +} diff --git a/app/app/main/explore/page/[pageId]/_page-root-view.tsx b/app/app/main/explore/page/[pageId]/_page-root-view.tsx index ae5b057..b2f7580 100644 --- a/app/app/main/explore/page/[pageId]/_page-root-view.tsx +++ b/app/app/main/explore/page/[pageId]/_page-root-view.tsx @@ -1,72 +1,9 @@ -import { useIsFullscreen } from "@/hooks/use-is-fullscreen"; -import { getCityAtom } from "@/utils/bookmarks"; -import { MapMarker, MarkerType, useMapStore } from "@/utils/store"; -import { NodeType, RootNode } from "@bcye/structured-wikivoyage-types"; -import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { RootNode } from "@bcye/structured-wikivoyage-types"; import { UseQueryResult } from "@tanstack/react-query"; -import { Link, Route, Stack } from "expo-router"; -import { useAtomValue } from "jotai/react"; -import { filter, map, split, splitEvery } from "ramda"; -import { useEffect } from "react"; -import { ScrollView } from "react-native"; -import { Card, SkeletonView, Text, View } from "react-native-ui-lib"; - -function PageContent({ - pageQuery, - id, -}: { - pageQuery: UseQueryResult; - id: string; -}) { - const bookmarks = useAtomValue(getCityAtom(id)); - const registerMarker = useMapStore((s) => s.registerMarker); - const deregisterMarker = useMapStore((s) => s.deregisterMarker); - - useEffect( - function registerBookmarks() { - const markers: MapMarker[] = []; - for (const [bId, bookmark] of Object.entries(bookmarks)) { - const [lat, long] = map(parseFloat, split(",", bId)); - const marker: MapMarker = { - id: bId, - // somehow broken else - link: `/main/explore/page/${id}/section/${bookmark.section}` as Route, - lat, - long, - type: MarkerType.Bookmark, - }; - markers.push(marker); - registerMarker(marker); - } - - return () => { - for (const marker of markers) { - deregisterMarker(marker); - } - }; - }, - [bookmarks, id, registerMarker, deregisterMarker], - ); - - return map( - ([item1, item2]) => ( - - - {item2 && } - - ), - splitEvery( - 2, - filter((c) => c.type === NodeType.Section, pageQuery.data!.children), - ), - ); -} +import { Stack } from "expo-router"; +import { PageContent } from "../../_shared/page-content"; +import { ScrollContainer } from "../../_shared/scroll-container"; +import { SkeletonView, Text, View } from "react-native-ui-lib"; export default function PageRootView({ pageQuery, @@ -75,8 +12,6 @@ export default function PageRootView({ pageQuery: UseQueryResult; id: string | null; }) { - const isFullscreen = useIsFullscreen(); - return ( ) : pageQuery.data && id ? ( - !isFullscreen ? ( - - - - ) : ( - - - - ) + + + ) : null } /> ); } - -/** - * Renders a clickable infocard that links to a specific page section. - * - * This component creates a card-based link that navigates to a dynamic route structured as "/page/[pageId]/section/[title]". - * The card displays the provided title, offering a concise navigational element within the app. - * - * @param title - The title displayed on the card and used as part of the destination route. - * @param pageId - The identifier for the page, used to construct the dynamic navigation route. - */ -function Infocard({ title, pageId }: { title: string; pageId: string }) { - return ( - - - - - - ); -} diff --git a/app/app/main/explore/page/[pageId]/index.tsx b/app/app/main/explore/page/[pageId]/index.tsx index 8bd6a58..bb76eff 100644 --- a/app/app/main/explore/page/[pageId]/index.tsx +++ b/app/app/main/explore/page/[pageId]/index.tsx @@ -1,6 +1,6 @@ import useMoveTo from "@/hooks/use-move-to"; import useWikiQuery from "@/hooks/use-wiki-query"; -import { useLocalSearchParams } from "expo-router"; +import { Redirect, useLocalSearchParams } from "expo-router"; import { useEffect } from "react"; import PageRootView from "./_page-root-view"; @@ -12,16 +12,35 @@ export default function Page() { useEffect(() => { if (pageQuery.data) { - moveTo( - // @ts-ignore NEEDS FIXING WHEN GEO REVISED - parseFloat(pageQuery.data.properties.geo["2"]), - // @ts-ignore NEEDS FIXING WHEN GEO REVISED - parseFloat(pageQuery.data.properties.geo["1"]), - // @ts-ignore NEEDS FIXING WHEN GEO REVISED - parseFloat(pageQuery.data.properties.geo?.zoom ?? "13"), - ); + const hasGeo = + pageQuery.data.properties.geo && + pageQuery.data.properties.geo["1"] && + pageQuery.data.properties.geo["2"]; + + if (hasGeo) { + moveTo( + // @ts-ignore NEEDS FIXING WHEN GEO REVISED + parseFloat(pageQuery.data.properties.geo["2"]), + // @ts-ignore NEEDS FIXING WHEN GEO REVISED + parseFloat(pageQuery.data.properties.geo["1"]), + // @ts-ignore NEEDS FIXING WHEN GEO REVISED + parseFloat(pageQuery.data.properties.geo?.zoom ?? "13"), + ); + } } }, [pageQuery.data, moveTo]); + // Redirect to fullscreen route if page has no geo data + if (pageQuery.data) { + const hasGeo = + pageQuery.data.properties.geo && + pageQuery.data.properties.geo["1"] && + pageQuery.data.properties.geo["2"]; + + if (!hasGeo) { + return ; + } + } + return ; } diff --git a/app/app/main/explore/page/[pageId]/section/[title].tsx b/app/app/main/explore/page/[pageId]/section/[title].tsx index 7605b90..ccf8dd8 100644 --- a/app/app/main/explore/page/[pageId]/section/[title].tsx +++ b/app/app/main/explore/page/[pageId]/section/[title].tsx @@ -1,24 +1,14 @@ import WikiContent from "@/components/render-node"; -import { useIsFullscreen } from "@/hooks/use-is-fullscreen"; import { useScrollRef } from "@/hooks/use-scroll-ref"; import useWikiQuery from "@/hooks/use-wiki-query"; -import { citiesAtom, getCityAtom } from "@/utils/bookmarks"; import { NodeType, SectionNode } from "@bcye/structured-wikivoyage-types"; -import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Stack, useLocalSearchParams } from "expo-router"; -import { useAtom } from "jotai/react"; -import { append, assoc, dissoc } from "ramda"; -import { useCallback } from "react"; -import { ScrollView } from "react-native"; +import { ScrollContainer } from "../../../_shared/scroll-container"; +import { useSectionBookmarks } from "../../../_shared/use-section-bookmarks"; import { SkeletonView, View } from "react-native-ui-lib"; -import { toast } from "sonner-native"; /** - * Renders a specific section of a Wikipedia page in Markdown format. - * - * The component retrieves the page title and ID from local search parameters, fetches the page's wikitext - * via a TRPC query, and then extracts and converts the designated section—identified by the title—to Markdown. - * It slices off the section header from the converted Markdown and displays a skeleton view while the content loads. + * Renders a specific section of a Wikipedia page. */ export default function Section() { const { title, pageId } = useLocalSearchParams(); @@ -27,56 +17,13 @@ export default function Section() { (c) => c.type === NodeType.Section && c.properties.title === title, ) as SectionNode | undefined; const ref = useScrollRef(); - const isFullscreen = useIsFullscreen(); - const [bookmarks, setBookmarks] = useAtom(getCityAtom(pageId as string)); - const [cities, setCities] = useAtom(citiesAtom); const pageTitle = wikiQuery.data?.properties.title; - const isBookmarked = useCallback( - function isBookmarked(id: string) { - return !!bookmarks[id]; - }, - [bookmarks], - ); - - const toggleBookmarked = useCallback( - function toggleBookmarked(id: string) { - if (id == ",") { - toast.error( - "This listing doesn't have a location and can't be bookmarked. Add one on en.wikivoyage.org", - ); - return; - } - - if (isBookmarked(id)) { - setBookmarks(dissoc(id, bookmarks)); - } else { - if (!cities.find((c) => c.qid === pageId)) { - setCities( - append({ qid: pageId as string, name: pageTitle! }, cities), - ); - } - // no valid lat-lng - setBookmarks( - assoc( - id, - { section: title, properties: section!.properties }, - bookmarks, - ), - ); - } - }, - [ - bookmarks, - setBookmarks, - section, - isBookmarked, - pageTitle, - cities, - setCities, - pageId, - title, - ], + const { isBookmarked, toggleBookmarked } = useSectionBookmarks( + pageId as string, + title, + section, + pageTitle, ); if (!section) return null; @@ -87,27 +34,16 @@ export default function Section() { - !isFullscreen ? ( - - - - ) : ( - - - - ) - } + renderContent={() => ( + + + + )} /> );