diff options
Diffstat (limited to 'apps/mobile/app/dashboard/(tabs)')
15 files changed, 870 insertions, 599 deletions
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 ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Highlights" }} /> + </Stack> + ); +} 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 <FullPageError error={error.message} onRetry={() => refetch()} />; + } + + if (isPending || !data) { + return <FullPageSpinner />; + } + + const onRefresh = () => { + queryClient.invalidateQueries(api.highlights.getAll.pathFilter()); + }; + + return ( + <HighlightList + highlights={data.pages.flatMap((p) => 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 ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Home" }} /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx index 0a51b817..65034419 100644 --- a/apps/mobile/app/dashboard/(tabs)/index.tsx +++ b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx @@ -1,11 +1,9 @@ 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 { router, Stack } 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"; @@ -76,34 +74,35 @@ function HeaderRight({ export default function Home() { return ( - <CustomSafeAreaView> + <> + <Stack.Screen + options={{ + headerRight: () => ( + <HeaderRight + openNewBookmarkModal={() => + router.push("/dashboard/bookmarks/new") + } + /> + ), + }} + /> <UpdatingBookmarkList query={{ archived: false }} header={ - <View className="flex flex-col gap-1"> - <View className="flex flex-row justify-between"> - <PageTitle title="Home" className="pb-2" /> - <HeaderRight - openNewBookmarkModal={() => - router.push("/dashboard/bookmarks/new") - } - /> - </View> - <Pressable - className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1" - onPress={() => router.push("/dashboard/search")} - > - <TailwindResolver - className="text-muted" - comp={(styles) => ( - <Search size={16} color={styles?.color?.toString()} /> - )} - /> - <Text className="text-muted">Search</Text> - </Pressable> - </View> + <Pressable + className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1" + onPress={() => router.push("/dashboard/search")} + > + <TailwindResolver + className="text-muted" + comp={(styles) => ( + <Search size={16} color={styles?.color?.toString()} /> + )} + /> + <Text className="text-muted">Search</Text> + </Pressable> } /> - </CustomSafeAreaView> + </> ); } 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 ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Lists" }} /> + </Stack> + ); +} 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 ( + <Pressable + className="my-auto px-4" + onPress={() => { + Haptics.selectionAsync(); + openNewListModal(); + }} + > + <Plus color="rgb(0, 122, 255)" /> + </Pressable> + ); +} + +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<string, boolean>, + listStats?: Map<string, number>, + 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<Record<string, boolean>>( + {}, + ); + 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 <FullPageError error={error.message} onRetry={() => refetch()} />; + } + + if (!lists) { + return <FullPageSpinner />; + } + + 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 ( + <> + <Stack.Screen + options={{ + headerRight: () => ( + <HeaderRight + openNewListModal={() => router.push("/dashboard/lists/new")} + /> + ), + }} + /> + <FlatList + className="h-full" + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + gap: 6, + paddingBottom: 20, + }} + renderItem={(l) => ( + <View + className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2" + style={{ + borderCurve: "continuous", + ...condProps({ + condition: l.item.level > 0, + props: { marginLeft: l.item.level * 20 }, + }), + }} + > + {hasAnyListsWithChildren && ( + <View style={{ width: 32 }}> + {l.item.numChildren > 0 && ( + <Pressable + className="pr-2" + onPress={() => { + setShowChildrenOf((prev) => ({ + ...prev, + [l.item.id]: !prev[l.item.id], + })); + }} + > + <ChevronRight + color={colors.foreground} + style={{ + transform: [ + { rotate: l.item.collapsed ? "0deg" : "90deg" }, + ], + }} + /> + </Pressable> + )} + </View> + )} + + {l.item.isSharedSection ? ( + <Pressable + className="flex flex-1 flex-row items-center justify-between" + onPress={() => { + setShowChildrenOf((prev) => ({ + ...prev, + [l.item.id]: !prev[l.item.id], + })); + }} + > + <Text> + {l.item.logo} {l.item.name} + </Text> + </Pressable> + ) : ( + <Link + asChild + key={l.item.id} + href={l.item.href} + className="flex-1" + > + <Pressable className="flex flex-row items-center justify-between"> + <Text className="shrink"> + {l.item.logo} {l.item.name} + </Text> + <View className="flex flex-row items-center"> + {l.item.numBookmarks !== undefined && ( + <Text className="mr-2 text-xs text-muted-foreground"> + {l.item.numBookmarks} + </Text> + )} + <ChevronRight /> + </View> + </Pressable> + </Link> + )} + </View> + )} + 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 ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Settings" }} /> + </Stack> + ); +} 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 ( + <Text className="px-4 pb-1 pt-4 text-xs uppercase tracking-wide text-muted-foreground"> + {title} + </Text> + ); +} + +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 ( + <ScrollView + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }} + > + <UserProfileHeader + image={data?.image} + name={data?.name} + email={data?.email} + /> + + <SectionHeader title="Appearance" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link asChild href="/dashboard/settings/theme" className="flex-1"> + <Pressable className="flex flex-row justify-between"> + <Text>Theme</Text> + <View className="flex flex-row items-center gap-2"> + <Text className="text-muted-foreground"> + { + { light: "Light", dark: "Dark", system: "System" }[ + settings.theme + ] + } + </Text> + <ChevronRight /> + </View> + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link + asChild + href="/dashboard/settings/bookmark-default-view" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Default Bookmark View</Text> + <View className="flex flex-row items-center gap-2"> + {isSettingsLoading ? ( + <ActivityIndicator size="small" /> + ) : ( + <Text className="text-muted-foreground"> + {settings.defaultBookmarkView === "reader" + ? "Reader" + : "Browser"} + </Text> + )} + <ChevronRight /> + </View> + </Pressable> + </Link> + </View> + </View> + + <SectionHeader title="Reading" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link + asChild + href="/dashboard/settings/reader-settings" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Reader Text Settings</Text> + <ChevronRight /> + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Text className="flex-1" numberOfLines={1}> + Show notes in bookmark card + </Text> + <Switch + className="shrink-0" + value={settings.showNotes} + onValueChange={(value) => + setSettings({ + ...settings, + showNotes: value, + }) + } + /> + </View> + </View> + + <SectionHeader title="Media" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex w-full flex-row items-center justify-between gap-8 px-4 py-1"> + <Text>Upload Image Quality</Text> + <View className="flex flex-1 flex-row items-center justify-center gap-2"> + <Text className="text-foreground"> + {Math.round(settings.imageQuality * 100)}% + </Text> + <Slider + onSlidingComplete={(value) => + setSettings({ + ...settings, + imageQuality: Math.round(value) / 100, + }) + } + progress={imageQuality} + minimumValue={imageQualityMin} + maximumValue={imageQualityMax} + /> + </View> + </View> + </View> + + <SectionHeader title="Account" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <Pressable + className="flex flex-row items-center px-4 py-1" + onPress={logout} + > + <Text className="text-destructive">Log Out</Text> + </Pressable> + </View> + + <SectionHeader title="About" /> + <View + className="w-full rounded-xl bg-card py-2" + style={{ borderCurve: "continuous" }} + > + <View className="flex flex-row items-center justify-between px-4 py-1"> + <Text className="text-muted-foreground">Server</Text> + <Text className="text-sm text-muted-foreground"> + {isSettingsLoading ? "Loading..." : settings.address} + </Text> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between px-4 py-1"> + <Text className="text-muted-foreground">App Version</Text> + <Text className="text-sm text-muted-foreground"> + {Constants.expoConfig?.version ?? "unknown"} + </Text> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between px-4 py-1"> + <Text className="text-muted-foreground">Server Version</Text> + <Text className="text-sm text-muted-foreground"> + {isServerVersionLoading + ? "Loading..." + : serverVersionError + ? "unavailable" + : (serverVersion ?? "unknown")} + </Text> + </View> + </View> + </ScrollView> + ); +} 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 ( + <Stack + screenOptions={{ + headerLargeTitle: true, + headerTransparent: true, + headerBlurEffect: "systemMaterial", + headerShadowVisible: false, + headerLargeTitleShadowVisible: false, + headerLargeStyle: { backgroundColor: "transparent" }, + }} + > + <Stack.Screen name="index" options={{ title: "Tags" }} /> + </Stack> + ); +} 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 <FullPageError error={error.message} onRetry={() => refetch()} />; + } + + if (!data) { + return <FullPageSpinner />; + } + + 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 ( + <FlatList + className="h-full" + contentInsetAdjustmentBehavior="automatic" + ListHeaderComponent={ + <SearchInput + containerClassName="mx-2 mb-2" + placeholder="Search tags..." + value={searchQuery} + onChangeText={setSearchQuery} + /> + } + contentContainerStyle={{ + gap: 6, + paddingBottom: 20, + }} + renderItem={(item) => ( + <View + className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2" + style={{ borderCurve: "continuous" }} + > + <Link + asChild + key={item.item.id} + href={item.item.href} + className="flex-1" + > + <Pressable className="flex flex-row items-center justify-between"> + <View className="flex-1"> + <Text className="font-medium">{item.item.name}</Text> + <Text className="text-sm text-muted-foreground"> + {item.item.numBookmarks}{" "} + {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"} + </Text> + </View> + <ChevronRight /> + </Pressable> + </Link> + </View> + )} + data={tags} + refreshing={refreshing} + onRefresh={onRefresh} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isFetchingNextPage ? ( + <View className="py-4"> + <Text className="text-center text-muted-foreground"> + Loading more... + </Text> + </View> + ) : null + } + ListEmptyComponent={ + !isPending ? ( + <View className="py-8"> + <Text className="text-center text-muted-foreground"> + No tags yet + </Text> + </View> + ) : null + } + /> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx index 5cc6aa92..fd5798b9 100644 --- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx +++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx @@ -1,69 +1,62 @@ -import React, { useLayoutEffect } from "react"; -import { Tabs, useNavigation } from "expo-router"; -import { StyledTabs } from "@/components/navigation/tabs"; -import { useColorScheme } from "@/lib/useColorScheme"; +import React from "react"; import { - ClipboardList, - Highlighter, - Home, - Settings, - Tag, -} from "lucide-react-native"; + Icon, + Label, + NativeTabs, + VectorIcon, +} from "expo-router/unstable-native-tabs"; +import { useColorScheme } from "@/lib/useColorScheme"; +import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; export default function TabLayout() { const { colors } = useColorScheme(); - const navigation = useNavigation(); - // Hide the header on the parent screen - useLayoutEffect(() => { - navigation.setOptions({ - headerShown: false, - }); - }, [navigation]); - return ( - <StyledTabs - tabBarClassName="bg-gray-100 dark:bg-background" - sceneClassName="bg-gray-100 dark:bg-background" - screenOptions={{ - headerShown: false, - tabBarActiveTintColor: colors.foreground, - }} - > - <Tabs.Screen - name="index" - options={{ - title: "Home", - tabBarIcon: ({ color }) => <Home color={color} />, - }} - /> - <Tabs.Screen - name="lists" - options={{ - title: "Lists", - tabBarIcon: ({ color }) => <ClipboardList color={color} />, - }} - /> - <Tabs.Screen - name="tags" - options={{ - title: "Tags", - tabBarIcon: ({ color }) => <Tag color={color} />, - }} - /> - <Tabs.Screen - name="highlights" - options={{ - title: "Highlights", - tabBarIcon: ({ color }) => <Highlighter color={color} />, - }} - /> - <Tabs.Screen - name="settings" - options={{ - title: "Settings", - tabBarIcon: ({ color }) => <Settings color={color} />, - }} - /> - </StyledTabs> + <NativeTabs backgroundColor={colors.grey6} minimizeBehavior="onScrollDown"> + <NativeTabs.Trigger name="(home)"> + <Icon + sf="house.fill" + androidSrc={ + <VectorIcon family={MaterialCommunityIcons} name="home" /> + } + /> + <Label>Home</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(lists)"> + <Icon + sf="list.clipboard.fill" + androidSrc={ + <VectorIcon family={MaterialCommunityIcons} name="clipboard-list" /> + } + /> + <Label>Lists</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(tags)"> + <Icon + sf="tag.fill" + androidSrc={<VectorIcon family={MaterialCommunityIcons} name="tag" />} + /> + <Label>Tags</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(highlights)"> + <Icon + sf="highlighter" + androidSrc={ + <VectorIcon family={MaterialCommunityIcons} name="marker" /> + } + /> + <Label>Highlights</Label> + </NativeTabs.Trigger> + + <NativeTabs.Trigger name="(settings)"> + <Icon + sf="gearshape.fill" + androidSrc={<VectorIcon family={MaterialCommunityIcons} name="cog" />} + /> + <Label>Settings</Label> + </NativeTabs.Trigger> + </NativeTabs> ); } diff --git a/apps/mobile/app/dashboard/(tabs)/highlights.tsx b/apps/mobile/app/dashboard/(tabs)/highlights.tsx deleted file mode 100644 index 7879081b..00000000 --- a/apps/mobile/app/dashboard/(tabs)/highlights.tsx +++ /dev/null @@ -1,56 +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 { api } from "@karakeep/shared-react/trpc"; - -export default function Highlights() { - const apiUtils = api.useUtils(); - const { - data, - isPending, - isPlaceholderData, - error, - fetchNextPage, - isFetchingNextPage, - refetch, - } = api.highlights.getAll.useInfiniteQuery( - {}, - { - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); - - if (error) { - return <FullPageError error={error.message} onRetry={() => refetch()} />; - } - - if (isPending || !data) { - return <FullPageSpinner />; - } - - const onRefresh = () => { - apiUtils.highlights.getAll.invalidate(); - }; - - return ( - <CustomSafeAreaView> - <HighlightList - highlights={data.pages.flatMap((p) => p.highlights)} - header={ - <View className="flex flex-row justify-between"> - <PageTitle title="Highlights" /> - </View> - } - onRefresh={onRefresh} - fetchNextPage={fetchNextPage} - isFetchingNextPage={isFetchingNextPage} - isRefreshing={isPending || isPlaceholderData} - /> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx deleted file mode 100644 index e40be1a5..00000000 --- a/apps/mobile/app/dashboard/(tabs)/lists.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useEffect, 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 { api } from "@/lib/trpc"; -import { useColorScheme } from "@/lib/useColorScheme"; -import { condProps } from "@/lib/utils"; -import { Plus } from "lucide-react-native"; - -import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; -import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; - -function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) { - return ( - <Pressable - className="my-auto px-4" - onPress={() => { - Haptics.selectionAsync(); - openNewListModal(); - }} - > - <Plus color="rgb(0, 122, 255)" /> - </Pressable> - ); -} - -interface ListLink { - id: string; - logo: string; - name: string; - href: string; - level: number; - parent?: string; - numChildren: number; - collapsed: boolean; -} - -function traverseTree( - node: ZBookmarkListTreeNode, - links: ListLink[], - showChildrenOf: Record<string, boolean>, - 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], - }); - - if (node.children && showChildrenOf[node.item.id]) { - node.children.forEach((child) => - traverseTree(child, links, showChildrenOf, 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<Record<string, boolean>>( - {}, - ); - const apiUtils = api.useUtils(); - - useEffect(() => { - setRefreshing(isPending); - }, [isPending]); - - if (error) { - return <FullPageError error={error.message} onRetry={() => refetch()} />; - } - - if (!lists) { - return <FullPageSpinner />; - } - - const onRefresh = () => { - apiUtils.lists.list.invalidate(); - }; - - 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, - }, - ]; - - Object.values(lists.root).forEach((list) => - traverseTree(list, links, showChildrenOf), - ); - - return ( - <CustomSafeAreaView> - <FlatList - className="h-full" - ListHeaderComponent={ - <View className="flex flex-row justify-between"> - <PageTitle title="Lists" /> - <HeaderRight - openNewListModal={() => router.push("/dashboard/lists/new")} - /> - </View> - } - contentContainerStyle={{ - gap: 5, - }} - renderItem={(l) => ( - <View - className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2" - style={condProps({ - condition: l.item.level > 0, - props: { marginLeft: l.item.level * 20 }, - })} - > - {l.item.numChildren > 0 && ( - <Pressable - className="pr-2" - onPress={() => { - setShowChildrenOf((prev) => ({ - ...prev, - [l.item.id]: !prev[l.item.id], - })); - }} - > - <ChevronRight - color={colors.foreground} - style={{ - transform: [ - { rotate: l.item.collapsed ? "0deg" : "90deg" }, - ], - }} - /> - </Pressable> - )} - - <Link asChild key={l.item.id} href={l.item.href} className="flex-1"> - <Pressable className="flex flex-row items-center justify-between"> - <Text> - {l.item.logo} {l.item.name} - </Text> - <ChevronRight /> - </Pressable> - </Link> - </View> - )} - data={links} - refreshing={refreshing} - onRefresh={onRefresh} - /> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx deleted file mode 100644 index 76216e00..00000000 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ /dev/null @@ -1,135 +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 { Link } from "expo-router"; -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 PageTitle from "@/components/ui/PageTitle"; -import { Text } from "@/components/ui/Text"; -import { useSession } from "@/lib/session"; -import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; - -export default function Dashboard() { - const { logout } = useSession(); - const { - settings, - setSettings, - isLoading: isSettingsLoading, - } = useAppSettings(); - - const imageQuality = useSharedValue(0); - const imageQualityMin = useSharedValue(0); - const imageQualityMax = useSharedValue(100); - - useEffect(() => { - imageQuality.value = settings.imageQuality * 100; - }, [settings]); - - const { data, error, isLoading } = api.users.whoami.useQuery(); - - if (error?.data?.code === "UNAUTHORIZED") { - logout(); - } - - return ( - <CustomSafeAreaView> - <PageTitle title="Settings" /> - <View className="flex h-full w-full items-center gap-3 px-4 py-2"> - <View className="flex w-full gap-3 rounded-lg bg-card px-4 py-2"> - <Text>{isSettingsLoading ? "Loading ..." : settings.address}</Text> - <Divider orientation="horizontal" /> - <Text>{isLoading ? "Loading ..." : data?.email}</Text> - </View> - <Text className="w-full p-1 text-2xl font-bold text-foreground"> - App Settings - </Text> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Link asChild href="/dashboard/settings/theme" className="flex-1"> - <Pressable className="flex flex-row justify-between"> - <Text>Theme</Text> - <View className="flex flex-row items-center gap-2"> - <Text className="text-muted-foreground"> - { - { light: "Light", dark: "Dark", system: "System" }[ - settings.theme - ] - } - </Text> - <ChevronRight /> - </View> - </Pressable> - </Link> - </View> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Link - asChild - href="/dashboard/settings/bookmark-default-view" - className="flex-1" - > - <Pressable className="flex flex-row justify-between"> - <Text>Default Bookmark View</Text> - <View className="flex flex-row items-center gap-2"> - {isSettingsLoading ? ( - <ActivityIndicator size="small" /> - ) : ( - <Text className="text-muted-foreground"> - {settings.defaultBookmarkView === "reader" - ? "Reader" - : "Browser"} - </Text> - )} - <ChevronRight /> - </View> - </Pressable> - </Link> - </View> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Text>Show note preview in bookmark</Text> - <Switch - value={settings.showNotes} - onValueChange={(value) => - setSettings({ - ...settings, - showNotes: value, - }) - } - /> - </View> - <Text className="w-full p-1 text-2xl font-bold text-foreground"> - Upload Settings - </Text> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Text>Image Quality</Text> - <View className="flex flex-1 flex-row items-center justify-center gap-2"> - <Text className="text-foreground"> - {Math.round(settings.imageQuality * 100)}% - </Text> - <Slider - onSlidingComplete={(value) => - setSettings({ - ...settings, - imageQuality: Math.round(value) / 100, - }) - } - progress={imageQuality} - minimumValue={imageQualityMin} - maximumValue={imageQualityMax} - /> - </View> - </View> - <Divider orientation="horizontal" /> - <Button - androidRootClassName="w-full" - onPress={logout} - variant="destructive" - > - <Text>Log Out</Text> - </Button> - </View> - </CustomSafeAreaView> - ); -} diff --git a/apps/mobile/app/dashboard/(tabs)/tags.tsx b/apps/mobile/app/dashboard/(tabs)/tags.tsx deleted file mode 100644 index 7f3e4ac7..00000000 --- a/apps/mobile/app/dashboard/(tabs)/tags.tsx +++ /dev/null @@ -1,140 +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 { api } from "@/lib/trpc"; - -import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags"; -import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; - -interface TagItem { - id: string; - name: string; - numBookmarks: number; - href: string; -} - -export default function Tags() { - const [refreshing, setRefreshing] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const apiUtils = api.useUtils(); - - // 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 <FullPageError error={error.message} onRetry={() => refetch()} />; - } - - if (!data) { - return <FullPageSpinner />; - } - - const onRefresh = () => { - apiUtils.tags.list.invalidate(); - }; - - 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 ( - <CustomSafeAreaView> - <FlatList - className="h-full" - ListHeaderComponent={ - <View> - <PageTitle title="Tags" /> - <SearchInput - containerClassName="mx-2 mb-2" - placeholder="Search tags..." - value={searchQuery} - onChangeText={setSearchQuery} - /> - </View> - } - contentContainerStyle={{ - gap: 5, - }} - renderItem={(item) => ( - <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2"> - <Link - asChild - key={item.item.id} - href={item.item.href} - className="flex-1" - > - <Pressable className="flex flex-row justify-between"> - <View className="flex-1"> - <Text className="font-medium">{item.item.name}</Text> - <Text className="text-sm text-muted-foreground"> - {item.item.numBookmarks}{" "} - {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"} - </Text> - </View> - <ChevronRight /> - </Pressable> - </Link> - </View> - )} - data={tags} - refreshing={refreshing} - onRefresh={onRefresh} - onEndReached={handleLoadMore} - onEndReachedThreshold={0.5} - ListFooterComponent={ - isFetchingNextPage ? ( - <View className="py-4"> - <Text className="text-center text-muted-foreground"> - Loading more... - </Text> - </View> - ) : null - } - ListEmptyComponent={ - !isPending ? ( - <View className="py-8"> - <Text className="text-center text-muted-foreground"> - No tags yet - </Text> - </View> - ) : null - } - /> - </CustomSafeAreaView> - ); -} |
