From e455e46852900c6d2b3e77b7a77e1b9da41b2ca8 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 8 Feb 2026 22:45:32 +0000 Subject: feat(mobile): more native screens --- .../app/dashboard/(tabs)/(highlights)/_layout.tsx | 18 ++ .../app/dashboard/(tabs)/(highlights)/index.tsx | 50 ++++ .../mobile/app/dashboard/(tabs)/(home)/_layout.tsx | 18 ++ apps/mobile/app/dashboard/(tabs)/(home)/index.tsx | 108 ++++++++ .../app/dashboard/(tabs)/(lists)/_layout.tsx | 18 ++ apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx | 284 +++++++++++++++++++++ .../app/dashboard/(tabs)/(settings)/_layout.tsx | 18 ++ .../app/dashboard/(tabs)/(settings)/index.tsx | 225 ++++++++++++++++ .../mobile/app/dashboard/(tabs)/(tags)/_layout.tsx | 18 ++ apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx | 140 ++++++++++ apps/mobile/app/dashboard/(tabs)/_layout.tsx | 10 +- apps/mobile/app/dashboard/(tabs)/highlights.tsx | 60 ----- apps/mobile/app/dashboard/(tabs)/index.tsx | 109 -------- apps/mobile/app/dashboard/(tabs)/lists.tsx | 280 -------------------- apps/mobile/app/dashboard/(tabs)/settings.tsx | 176 ------------- apps/mobile/app/dashboard/(tabs)/tags.tsx | 142 ----------- apps/mobile/app/dashboard/_layout.tsx | 6 +- apps/mobile/app/dashboard/bookmarks/new.tsx | 37 ++- 18 files changed, 923 insertions(+), 794 deletions(-) create mode 100644 apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(home)/index.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx create mode 100644 apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx delete mode 100644 apps/mobile/app/dashboard/(tabs)/highlights.tsx delete mode 100644 apps/mobile/app/dashboard/(tabs)/index.tsx delete mode 100644 apps/mobile/app/dashboard/(tabs)/lists.tsx delete mode 100644 apps/mobile/app/dashboard/(tabs)/settings.tsx delete mode 100644 apps/mobile/app/dashboard/(tabs)/tags.tsx (limited to 'apps/mobile/app') diff --git a/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx new file mode 100644 index 00000000..961df836 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx new file mode 100644 index 00000000..48a190c1 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx @@ -0,0 +1,50 @@ +import FullPageError from "@/components/FullPageError"; +import HighlightList from "@/components/highlights/HighlightList"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +export default function Highlights() { + const api = useTRPC(); + const queryClient = useQueryClient(); + const { + data, + isPending, + isPlaceholderData, + error, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteQuery( + api.highlights.getAll.infiniteQueryOptions( + {}, + { + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), + ); + + if (error) { + return refetch()} />; + } + + if (isPending || !data) { + return ; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.highlights.getAll.pathFilter()); + }; + + return ( + p.highlights)} + onRefresh={onRefresh} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + isRefreshing={isPending || isPlaceholderData} + /> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx new file mode 100644 index 00000000..1ba65211 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx new file mode 100644 index 00000000..65034419 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx @@ -0,0 +1,108 @@ +import { Platform, Pressable, View } from "react-native"; +import * as Haptics from "expo-haptics"; +import * as ImagePicker from "expo-image-picker"; +import { router, Stack } from "expo-router"; +import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; +import { TailwindResolver } from "@/components/TailwindResolver"; +import { Text } from "@/components/ui/Text"; +import { useToast } from "@/components/ui/Toast"; +import useAppSettings from "@/lib/settings"; +import { useUploadAsset } from "@/lib/upload"; +import { MenuView } from "@react-native-menu/menu"; +import { Plus, Search } from "lucide-react-native"; + +function HeaderRight({ + openNewBookmarkModal, +}: { + openNewBookmarkModal: () => void; +}) { + const { toast } = useToast(); + const { settings } = useAppSettings(); + const { uploadAsset } = useUploadAsset(settings, { + onError: (e) => { + toast({ message: e, variant: "destructive" }); + }, + }); + return ( + { + Haptics.selectionAsync(); + if (nativeEvent.event === "new") { + openNewBookmarkModal(); + } else if (nativeEvent.event === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: settings.imageQuality, + allowsMultipleSelection: false, + }); + if (!result.canceled) { + uploadAsset({ + type: result.assets[0].mimeType ?? "", + name: result.assets[0].fileName ?? "", + uri: result.assets[0].uri, + }); + } + } + }} + actions={[ + { + id: "new", + title: "New Bookmark", + image: Platform.select({ + ios: "note.text", + }), + }, + { + id: "library", + title: "Photo Library", + image: Platform.select({ + ios: "photo", + }), + }, + ]} + shouldOpenOnLongPress={false} + > + + Haptics.selectionAsync()} + /> + + + ); +} + +export default function Home() { + return ( + <> + ( + + router.push("/dashboard/bookmarks/new") + } + /> + ), + }} + /> + router.push("/dashboard/search")} + > + ( + + )} + /> + Search + + } + /> + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx new file mode 100644 index 00000000..398ba650 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx new file mode 100644 index 00000000..4c98ef2c --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx @@ -0,0 +1,284 @@ +import { useEffect, useMemo, useState } from "react"; +import { FlatList, Pressable, View } from "react-native"; +import * as Haptics from "expo-haptics"; +import { Link, router, Stack } from "expo-router"; +import FullPageError from "@/components/FullPageError"; +import ChevronRight from "@/components/ui/ChevronRight"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { Text } from "@/components/ui/Text"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { condProps } from "@/lib/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react-native"; + +import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; + +function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) { + return ( + { + Haptics.selectionAsync(); + openNewListModal(); + }} + > + + + ); +} + +interface ListLink { + id: string; + logo: string; + name: string; + href: string; + level: number; + parent?: string; + numChildren: number; + collapsed: boolean; + isSharedSection?: boolean; + numBookmarks?: number; +} + +function traverseTree( + node: ZBookmarkListTreeNode, + links: ListLink[], + showChildrenOf: Record, + listStats?: Map, + parent?: string, + level = 0, +) { + links.push({ + id: node.item.id, + logo: node.item.icon, + name: node.item.name, + href: `/dashboard/lists/${node.item.id}`, + level, + parent, + numChildren: node.children?.length ?? 0, + collapsed: !showChildrenOf[node.item.id], + numBookmarks: listStats?.get(node.item.id), + }); + + if (node.children && showChildrenOf[node.item.id]) { + node.children.forEach((child) => + traverseTree( + child, + links, + showChildrenOf, + listStats, + node.item.id, + level + 1, + ), + ); + } +} + +export default function Lists() { + const { colors } = useColorScheme(); + const [refreshing, setRefreshing] = useState(false); + const { data: lists, isPending, error, refetch } = useBookmarkLists(); + const [showChildrenOf, setShowChildrenOf] = useState>( + {}, + ); + const api = useTRPC(); + const queryClient = useQueryClient(); + const { data: listStats } = useQuery(api.lists.stats.queryOptions()); + + // Check if there are any shared lists + const hasSharedLists = useMemo(() => { + return lists?.data.some((list) => list.userRole !== "owner") ?? false; + }, [lists?.data]); + + // Check if any list has children to determine if we need chevron spacing + const hasAnyListsWithChildren = useMemo(() => { + const checkForChildren = (node: ZBookmarkListTreeNode): boolean => { + if (node.children && node.children.length > 0) return true; + return false; + }; + return ( + Object.values(lists?.root ?? {}).some(checkForChildren) || hasSharedLists + ); + }, [lists?.root, hasSharedLists]); + + useEffect(() => { + setRefreshing(isPending); + }, [isPending]); + + if (error) { + return refetch()} />; + } + + if (!lists) { + return ; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.lists.list.pathFilter()); + queryClient.invalidateQueries(api.lists.stats.pathFilter()); + }; + + const links: ListLink[] = [ + { + id: "fav", + logo: "⭐️", + name: "Favourites", + href: "/dashboard/favourites", + level: 0, + numChildren: 0, + collapsed: false, + }, + { + id: "arch", + logo: "🗄️", + name: "Archive", + href: "/dashboard/archive", + level: 0, + numChildren: 0, + collapsed: false, + }, + ]; + + // Add shared lists section if there are any + if (hasSharedLists) { + // Count shared lists to determine if section has children + const sharedListsCount = Object.values(lists.root).filter( + (list) => list.item.userRole !== "owner", + ).length; + + links.push({ + id: "shared-section", + logo: "👥", + name: "Shared Lists", + href: "#", + level: 0, + numChildren: sharedListsCount, + collapsed: !showChildrenOf["shared-section"], + isSharedSection: true, + }); + + // Add shared lists as children if section is expanded + if (showChildrenOf["shared-section"]) { + Object.values(lists.root).forEach((list) => { + if (list.item.userRole !== "owner") { + traverseTree( + list, + links, + showChildrenOf, + listStats?.stats, + "shared-section", + 1, + ); + } + }); + } + } + + // Add owned lists only + Object.values(lists.root).forEach((list) => { + if (list.item.userRole === "owner") { + traverseTree(list, links, showChildrenOf, listStats?.stats); + } + }); + + return ( + <> + ( + router.push("/dashboard/lists/new")} + /> + ), + }} + /> + ( + 0, + props: { marginLeft: l.item.level * 20 }, + }), + }} + > + {hasAnyListsWithChildren && ( + + {l.item.numChildren > 0 && ( + { + setShowChildrenOf((prev) => ({ + ...prev, + [l.item.id]: !prev[l.item.id], + })); + }} + > + + + )} + + )} + + {l.item.isSharedSection ? ( + { + setShowChildrenOf((prev) => ({ + ...prev, + [l.item.id]: !prev[l.item.id], + })); + }} + > + + {l.item.logo} {l.item.name} + + + ) : ( + + + + {l.item.logo} {l.item.name} + + + {l.item.numBookmarks !== undefined && ( + + {l.item.numBookmarks} + + )} + + + + + )} + + )} + data={links} + refreshing={refreshing} + onRefresh={onRefresh} + /> + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx new file mode 100644 index 00000000..8c51d5a3 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx new file mode 100644 index 00000000..de17ff5a --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx @@ -0,0 +1,225 @@ +import { useEffect } from "react"; +import { + ActivityIndicator, + Pressable, + ScrollView, + Switch, + View, +} from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { useSharedValue } from "react-native-reanimated"; +import Constants from "expo-constants"; +import { Link } from "expo-router"; +import { UserProfileHeader } from "@/components/settings/UserProfileHeader"; +import ChevronRight from "@/components/ui/ChevronRight"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { useServerVersion } from "@/lib/hooks"; +import { useSession } from "@/lib/session"; +import useAppSettings from "@/lib/settings"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +function SectionHeader({ title }: { title: string }) { + return ( + + {title} + + ); +} + +export default function Settings() { + const { logout } = useSession(); + const { + settings, + setSettings, + isLoading: isSettingsLoading, + } = useAppSettings(); + const api = useTRPC(); + + const imageQuality = useSharedValue(0); + const imageQualityMin = useSharedValue(0); + const imageQualityMax = useSharedValue(100); + + useEffect(() => { + imageQuality.value = settings.imageQuality * 100; + }, [settings]); + + const { data, error } = useQuery(api.users.whoami.queryOptions()); + const { + data: serverVersion, + isLoading: isServerVersionLoading, + error: serverVersionError, + } = useServerVersion(); + + if (error?.data?.code === "UNAUTHORIZED") { + logout(); + } + + return ( + + + + + + + + + Theme + + + { + { light: "Light", dark: "Dark", system: "System" }[ + settings.theme + ] + } + + + + + + + + + + + Default Bookmark View + + {isSettingsLoading ? ( + + ) : ( + + {settings.defaultBookmarkView === "reader" + ? "Reader" + : "Browser"} + + )} + + + + + + + + + + + + + Reader Text Settings + + + + + + + + Show notes in bookmark card + + + setSettings({ + ...settings, + showNotes: value, + }) + } + /> + + + + + + + Upload Image Quality + + + {Math.round(settings.imageQuality * 100)}% + + + setSettings({ + ...settings, + imageQuality: Math.round(value) / 100, + }) + } + progress={imageQuality} + minimumValue={imageQualityMin} + maximumValue={imageQualityMax} + /> + + + + + + + + Log Out + + + + + + + Server + + {isSettingsLoading ? "Loading..." : settings.address} + + + + + App Version + + {Constants.expoConfig?.version ?? "unknown"} + + + + + Server Version + + {isServerVersionLoading + ? "Loading..." + : serverVersionError + ? "unavailable" + : (serverVersion ?? "unknown")} + + + + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx new file mode 100644 index 00000000..3b56548f --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router/stack"; + +export default function Layout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx new file mode 100644 index 00000000..4903d681 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { FlatList, Pressable, View } from "react-native"; +import { Link } from "expo-router"; +import FullPageError from "@/components/FullPageError"; +import ChevronRight from "@/components/ui/ChevronRight"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { SearchInput } from "@/components/ui/SearchInput"; +import { Text } from "@/components/ui/Text"; +import { useQueryClient } from "@tanstack/react-query"; + +import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + +interface TagItem { + id: string; + name: string; + numBookmarks: number; + href: string; +} + +export default function Tags() { + const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const api = useTRPC(); + const queryClient = useQueryClient(); + + // Debounce search query to avoid too many API calls + const debouncedSearch = useDebounce(searchQuery, 300); + + // Fetch tags sorted by usage (most used first) + const { + data, + isPending, + error, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePaginatedSearchTags({ + limit: 50, + sortBy: debouncedSearch ? "relevance" : "usage", + nameContains: debouncedSearch, + }); + + useEffect(() => { + setRefreshing(isPending); + }, [isPending]); + + if (error) { + return refetch()} />; + } + + if (!data) { + return ; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.tags.list.pathFilter()); + }; + + const tags: TagItem[] = data.tags.map((tag) => ({ + id: tag.id, + name: tag.name, + numBookmarks: tag.numBookmarks, + href: `/dashboard/tags/${tag.id}`, + })); + + const handleLoadMore = () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + return ( + + } + contentContainerStyle={{ + gap: 6, + paddingBottom: 20, + }} + renderItem={(item) => ( + + + + + {item.item.name} + + {item.item.numBookmarks}{" "} + {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"} + + + + + + + )} + data={tags} + refreshing={refreshing} + onRefresh={onRefresh} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isFetchingNextPage ? ( + + + Loading more... + + + ) : null + } + ListEmptyComponent={ + !isPending ? ( + + + No tags yet + + + ) : null + } + /> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx index f3db822e..fd5798b9 100644 --- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx +++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx @@ -12,7 +12,7 @@ export default function TabLayout() { const { colors } = useColorScheme(); return ( - + Home - + Lists - + } @@ -40,7 +40,7 @@ export default function TabLayout() { - + Highlights - + } diff --git a/apps/mobile/app/dashboard/(tabs)/highlights.tsx b/apps/mobile/app/dashboard/(tabs)/highlights.tsx deleted file mode 100644 index 8d6e37a4..00000000 --- a/apps/mobile/app/dashboard/(tabs)/highlights.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { View } from "react-native"; -import FullPageError from "@/components/FullPageError"; -import HighlightList from "@/components/highlights/HighlightList"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import PageTitle from "@/components/ui/PageTitle"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; - -import { useTRPC } from "@karakeep/shared-react/trpc"; - -export default function Highlights() { - const api = useTRPC(); - const queryClient = useQueryClient(); - const { - data, - isPending, - isPlaceholderData, - error, - fetchNextPage, - isFetchingNextPage, - refetch, - } = useInfiniteQuery( - api.highlights.getAll.infiniteQueryOptions( - {}, - { - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ), - ); - - if (error) { - return refetch()} />; - } - - if (isPending || !data) { - return ; - } - - const onRefresh = () => { - queryClient.invalidateQueries(api.highlights.getAll.pathFilter()); - }; - - return ( - - p.highlights)} - header={ - - - - } - onRefresh={onRefresh} - fetchNextPage={fetchNextPage} - isFetchingNextPage={isFetchingNextPage} - isRefreshing={isPending || isPlaceholderData} - /> - - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx deleted file mode 100644 index 3e49e6f2..00000000 --- a/apps/mobile/app/dashboard/(tabs)/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Platform, Pressable, View } from "react-native"; -import * as Haptics from "expo-haptics"; -import * as ImagePicker from "expo-image-picker"; -import { router } from "expo-router"; -import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; -import { TailwindResolver } from "@/components/TailwindResolver"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import PageTitle from "@/components/ui/PageTitle"; -import { Text } from "@/components/ui/Text"; -import { useToast } from "@/components/ui/Toast"; -import useAppSettings from "@/lib/settings"; -import { useUploadAsset } from "@/lib/upload"; -import { MenuView } from "@react-native-menu/menu"; -import { Plus, Search } from "lucide-react-native"; - -function HeaderRight({ - openNewBookmarkModal, -}: { - openNewBookmarkModal: () => void; -}) { - const { toast } = useToast(); - const { settings } = useAppSettings(); - const { uploadAsset } = useUploadAsset(settings, { - onError: (e) => { - toast({ message: e, variant: "destructive" }); - }, - }); - return ( - { - Haptics.selectionAsync(); - if (nativeEvent.event === "new") { - openNewBookmarkModal(); - } else if (nativeEvent.event === "library") { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - quality: settings.imageQuality, - allowsMultipleSelection: false, - }); - if (!result.canceled) { - uploadAsset({ - type: result.assets[0].mimeType ?? "", - name: result.assets[0].fileName ?? "", - uri: result.assets[0].uri, - }); - } - } - }} - actions={[ - { - id: "new", - title: "New Bookmark", - image: Platform.select({ - ios: "note.text", - }), - }, - { - id: "library", - title: "Photo Library", - image: Platform.select({ - ios: "photo", - }), - }, - ]} - shouldOpenOnLongPress={false} - > - - Haptics.selectionAsync()} - /> - - - ); -} - -export default function Home() { - return ( - - - - - - router.push("/dashboard/bookmarks/new") - } - /> - - router.push("/dashboard/search")} - > - ( - - )} - /> - Search - - - } - /> - - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx deleted file mode 100644 index 3f81a36e..00000000 --- a/apps/mobile/app/dashboard/(tabs)/lists.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { FlatList, Pressable, View } from "react-native"; -import * as Haptics from "expo-haptics"; -import { Link, router } from "expo-router"; -import FullPageError from "@/components/FullPageError"; -import ChevronRight from "@/components/ui/ChevronRight"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import PageTitle from "@/components/ui/PageTitle"; -import { Text } from "@/components/ui/Text"; -import { useColorScheme } from "@/lib/useColorScheme"; -import { condProps } from "@/lib/utils"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus } from "lucide-react-native"; - -import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; -import { useTRPC } from "@karakeep/shared-react/trpc"; -import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; - -function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) { - return ( - { - Haptics.selectionAsync(); - openNewListModal(); - }} - > - - - ); -} - -interface ListLink { - id: string; - logo: string; - name: string; - href: string; - level: number; - parent?: string; - numChildren: number; - collapsed: boolean; - isSharedSection?: boolean; - numBookmarks?: number; -} - -function traverseTree( - node: ZBookmarkListTreeNode, - links: ListLink[], - showChildrenOf: Record, - listStats?: Map, - parent?: string, - level = 0, -) { - links.push({ - id: node.item.id, - logo: node.item.icon, - name: node.item.name, - href: `/dashboard/lists/${node.item.id}`, - level, - parent, - numChildren: node.children?.length ?? 0, - collapsed: !showChildrenOf[node.item.id], - numBookmarks: listStats?.get(node.item.id), - }); - - if (node.children && showChildrenOf[node.item.id]) { - node.children.forEach((child) => - traverseTree( - child, - links, - showChildrenOf, - listStats, - node.item.id, - level + 1, - ), - ); - } -} - -export default function Lists() { - const { colors } = useColorScheme(); - const [refreshing, setRefreshing] = useState(false); - const { data: lists, isPending, error, refetch } = useBookmarkLists(); - const [showChildrenOf, setShowChildrenOf] = useState>( - {}, - ); - const api = useTRPC(); - const queryClient = useQueryClient(); - const { data: listStats } = useQuery(api.lists.stats.queryOptions()); - - // Check if there are any shared lists - const hasSharedLists = useMemo(() => { - return lists?.data.some((list) => list.userRole !== "owner") ?? false; - }, [lists?.data]); - - // Check if any list has children to determine if we need chevron spacing - const hasAnyListsWithChildren = useMemo(() => { - const checkForChildren = (node: ZBookmarkListTreeNode): boolean => { - if (node.children && node.children.length > 0) return true; - return false; - }; - return ( - Object.values(lists?.root ?? {}).some(checkForChildren) || hasSharedLists - ); - }, [lists?.root, hasSharedLists]); - - useEffect(() => { - setRefreshing(isPending); - }, [isPending]); - - if (error) { - return refetch()} />; - } - - if (!lists) { - return ; - } - - const onRefresh = () => { - queryClient.invalidateQueries(api.lists.list.pathFilter()); - queryClient.invalidateQueries(api.lists.stats.pathFilter()); - }; - - const links: ListLink[] = [ - { - id: "fav", - logo: "⭐️", - name: "Favourites", - href: "/dashboard/favourites", - level: 0, - numChildren: 0, - collapsed: false, - }, - { - id: "arch", - logo: "🗄️", - name: "Archive", - href: "/dashboard/archive", - level: 0, - numChildren: 0, - collapsed: false, - }, - ]; - - // Add shared lists section if there are any - if (hasSharedLists) { - // Count shared lists to determine if section has children - const sharedListsCount = Object.values(lists.root).filter( - (list) => list.item.userRole !== "owner", - ).length; - - links.push({ - id: "shared-section", - logo: "👥", - name: "Shared Lists", - href: "#", - level: 0, - numChildren: sharedListsCount, - collapsed: !showChildrenOf["shared-section"], - isSharedSection: true, - }); - - // Add shared lists as children if section is expanded - if (showChildrenOf["shared-section"]) { - Object.values(lists.root).forEach((list) => { - if (list.item.userRole !== "owner") { - traverseTree( - list, - links, - showChildrenOf, - listStats?.stats, - "shared-section", - 1, - ); - } - }); - } - } - - // Add owned lists only - Object.values(lists.root).forEach((list) => { - if (list.item.userRole === "owner") { - traverseTree(list, links, showChildrenOf, listStats?.stats); - } - }); - - return ( - - - - router.push("/dashboard/lists/new")} - /> - - } - contentContainerStyle={{ - gap: 6, - }} - renderItem={(l) => ( - 0, - props: { marginLeft: l.item.level * 20 }, - })} - > - {hasAnyListsWithChildren && ( - - {l.item.numChildren > 0 && ( - { - setShowChildrenOf((prev) => ({ - ...prev, - [l.item.id]: !prev[l.item.id], - })); - }} - > - - - )} - - )} - - {l.item.isSharedSection ? ( - { - setShowChildrenOf((prev) => ({ - ...prev, - [l.item.id]: !prev[l.item.id], - })); - }} - > - - {l.item.logo} {l.item.name} - - - ) : ( - - - - {l.item.logo} {l.item.name} - - - {l.item.numBookmarks !== undefined && ( - - {l.item.numBookmarks} - - )} - - - - - )} - - )} - data={links} - refreshing={refreshing} - onRefresh={onRefresh} - /> - - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx deleted file mode 100644 index ba38d9e6..00000000 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useEffect } from "react"; -import { ActivityIndicator, Pressable, Switch, View } from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import { useSharedValue } from "react-native-reanimated"; -import Constants from "expo-constants"; -import { Link } from "expo-router"; -import { UserProfileHeader } from "@/components/settings/UserProfileHeader"; -import { Button } from "@/components/ui/Button"; -import ChevronRight from "@/components/ui/ChevronRight"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import { Divider } from "@/components/ui/Divider"; -import { Text } from "@/components/ui/Text"; -import { useServerVersion } from "@/lib/hooks"; -import { useSession } from "@/lib/session"; -import useAppSettings from "@/lib/settings"; -import { useQuery } from "@tanstack/react-query"; - -import { useTRPC } from "@karakeep/shared-react/trpc"; - -export default function Dashboard() { - const { logout } = useSession(); - const { - settings, - setSettings, - isLoading: isSettingsLoading, - } = useAppSettings(); - const api = useTRPC(); - - const imageQuality = useSharedValue(0); - const imageQualityMin = useSharedValue(0); - const imageQualityMax = useSharedValue(100); - - useEffect(() => { - imageQuality.value = settings.imageQuality * 100; - }, [settings]); - - const { data, error } = useQuery(api.users.whoami.queryOptions()); - const { - data: serverVersion, - isLoading: isServerVersionLoading, - error: serverVersionError, - } = useServerVersion(); - - if (error?.data?.code === "UNAUTHORIZED") { - logout(); - } - - return ( - - - - - - - - Theme - - - { - { light: "Light", dark: "Dark", system: "System" }[ - settings.theme - ] - } - - - - - - - - - - - Default Bookmark View - - {isSettingsLoading ? ( - - ) : ( - - {settings.defaultBookmarkView === "reader" - ? "Reader" - : "Browser"} - - )} - - - - - - - - - - Reader Text Settings - - - - - - - - Show notes in bookmark card - - - setSettings({ - ...settings, - showNotes: value, - }) - } - /> - - - - - - Upload Image Quality - - - {Math.round(settings.imageQuality * 100)}% - - - setSettings({ - ...settings, - imageQuality: Math.round(value) / 100, - }) - } - progress={imageQuality} - minimumValue={imageQualityMin} - maximumValue={imageQualityMax} - /> - - - - - - - {isSettingsLoading ? "Loading..." : settings.address} - - - App Version: {Constants.expoConfig?.version ?? "unknown"} - - - Server Version:{" "} - {isServerVersionLoading - ? "Loading..." - : serverVersionError - ? "unavailable" - : (serverVersion ?? "unknown")} - - - - - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/tags.tsx b/apps/mobile/app/dashboard/(tabs)/tags.tsx deleted file mode 100644 index 8a629305..00000000 --- a/apps/mobile/app/dashboard/(tabs)/tags.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useEffect, useState } from "react"; -import { FlatList, Pressable, View } from "react-native"; -import { Link } from "expo-router"; -import FullPageError from "@/components/FullPageError"; -import ChevronRight from "@/components/ui/ChevronRight"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; -import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import PageTitle from "@/components/ui/PageTitle"; -import { SearchInput } from "@/components/ui/SearchInput"; -import { Text } from "@/components/ui/Text"; -import { useQueryClient } from "@tanstack/react-query"; - -import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags"; -import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; -import { useTRPC } from "@karakeep/shared-react/trpc"; - -interface TagItem { - id: string; - name: string; - numBookmarks: number; - href: string; -} - -export default function Tags() { - const [refreshing, setRefreshing] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const api = useTRPC(); - const queryClient = useQueryClient(); - - // Debounce search query to avoid too many API calls - const debouncedSearch = useDebounce(searchQuery, 300); - - // Fetch tags sorted by usage (most used first) - const { - data, - isPending, - error, - refetch, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = usePaginatedSearchTags({ - limit: 50, - sortBy: debouncedSearch ? "relevance" : "usage", - nameContains: debouncedSearch, - }); - - useEffect(() => { - setRefreshing(isPending); - }, [isPending]); - - if (error) { - return refetch()} />; - } - - if (!data) { - return ; - } - - const onRefresh = () => { - queryClient.invalidateQueries(api.tags.list.pathFilter()); - }; - - const tags: TagItem[] = data.tags.map((tag) => ({ - id: tag.id, - name: tag.name, - numBookmarks: tag.numBookmarks, - href: `/dashboard/tags/${tag.id}`, - })); - - const handleLoadMore = () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - - return ( - - - - - - } - contentContainerStyle={{ - gap: 6, - }} - renderItem={(item) => ( - - - - - {item.item.name} - - {item.item.numBookmarks}{" "} - {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"} - - - - - - - )} - data={tags} - refreshing={refreshing} - onRefresh={onRefresh} - onEndReached={handleLoadMore} - onEndReachedThreshold={0.5} - ListFooterComponent={ - isFetchingNextPage ? ( - - - Loading more... - - - ) : null - } - ListEmptyComponent={ - !isPending ? ( - - - No tags yet - - - ) : null - } - /> - - ); -} diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx index 60fbc4fc..78fd7c60 100644 --- a/apps/mobile/app/dashboard/_layout.tsx +++ b/apps/mobile/app/dashboard/_layout.tsx @@ -70,8 +70,10 @@ export default function Dashboard() { options={{ headerTitle: "New Bookmark", headerBackTitle: "Back", - headerTransparent: true, - presentation: "modal", + headerTransparent: false, + presentation: "formSheet", + sheetGrabberVisible: true, + sheetAllowedDetents: [0.35, 0.7], }} /> { }; return ( - - - {error && ( - {error} - )} - - - - + + {error && ( + {error} + )} + + + ); }; -- cgit v1.2.3-70-g09d2