diff options
Diffstat (limited to 'apps/mobile/app/dashboard')
27 files changed, 1569 insertions, 730 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> - ); -} diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx index eb1cbe4b..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], }} /> <Stack.Screen @@ -110,6 +112,15 @@ export default function Dashboard() { }} /> <Stack.Screen + name="lists/[slug]/edit" + options={{ + headerTitle: "Edit List", + headerBackTitle: "Back", + headerTransparent: true, + presentation: "modal", + }} + /> + <Stack.Screen name="archive" options={{ headerTitle: "", @@ -144,6 +155,14 @@ export default function Dashboard() { headerBackTitle: "Back", }} /> + <Stack.Screen + name="settings/reader-settings" + options={{ + title: "Reader Settings", + headerTitle: "Reader Settings", + headerBackTitle: "Back", + }} + /> </StyledStack> ); } diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7bf0f118..efb82b1e 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { KeyboardAvoidingView } from "react-native"; +import { KeyboardAvoidingView, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; import BookmarkLinkTypeSelector, { BookmarkLinkType, @@ -12,17 +12,21 @@ import BottomActions from "@/components/bookmarks/BottomActions"; import FullPageError from "@/components/FullPageError"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import useAppSettings from "@/lib/settings"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { Settings } from "lucide-react-native"; import { useColorScheme } from "nativewind"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; export default function BookmarkView() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const { settings } = useAppSettings(); + const api = useTRPC(); const [bookmarkLinkType, setBookmarkLinkType] = useState<BookmarkLinkType>( settings.defaultBookmarkView, @@ -36,10 +40,12 @@ export default function BookmarkView() { data: bookmark, error, refetch, - } = api.bookmarks.getBookmark.useQuery({ - bookmarkId: slug, - includeContent: false, - }); + } = useQuery( + api.bookmarks.getBookmark.queryOptions({ + bookmarkId: slug, + includeContent: false, + }), + ); if (error) { return <FullPageError error={error.message} onRetry={refetch} />; @@ -87,11 +93,22 @@ export default function BookmarkView() { headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( - <BookmarkLinkTypeSelector - type={bookmarkLinkType} - onChange={(type) => setBookmarkLinkType(type)} - bookmark={bookmark} - /> + <View className="flex-row items-center gap-3"> + {bookmarkLinkType === "reader" && ( + <Pressable + onPress={() => + router.push("/dashboard/settings/reader-settings") + } + > + <Settings size={20} color="gray" /> + </Pressable> + )} + <BookmarkLinkTypeSelector + type={bookmarkLinkType} + onChange={(type) => setBookmarkLinkType(type)} + bookmark={bookmark} + /> + </View> ) : undefined, }} /> diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx index c4b76aef..744b7f7d 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx @@ -477,14 +477,14 @@ const ViewBookmarkPage = () => { </Button> </View> )} - <View className="gap-2"> - <Text className="items-center text-center"> + <View className="gap-1"> + <Text className="text-center text-xs text-muted-foreground"> Created {bookmark.createdAt.toLocaleString()} </Text> {bookmark.modifiedAt && bookmark.modifiedAt.getTime() !== bookmark.createdAt.getTime() && ( - <Text className="items-center text-center"> + <Text className="text-center text-xs text-muted-foreground"> Modified {bookmark.modifiedAt.toLocaleString()} </Text> )} diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx index c502c07f..1070207b 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx @@ -1,19 +1,22 @@ import React from "react"; -import { FlatList, Pressable, View } from "react-native"; +import { ActivityIndicator, FlatList, Pressable, View } from "react-native"; import Checkbox from "expo-checkbox"; import { useLocalSearchParams } from "expo-router"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; +import { useQuery } from "@tanstack/react-query"; +import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { useAddBookmarkToList, useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; const ListPickerPage = () => { + const api = useTRPC(); const { slug: bookmarkId } = useLocalSearchParams(); if (typeof bookmarkId !== "string") { throw new Error("Unexpected param type"); @@ -26,17 +29,24 @@ const ListPickerPage = () => { showProgress: false, }); }; - const { data: existingLists } = api.lists.getListsOfBookmark.useQuery( - { - bookmarkId, - }, - { - select: (data) => new Set(data.lists.map((l) => l.id)), - }, + const { data: existingLists } = useQuery( + api.lists.getListsOfBookmark.queryOptions( + { + bookmarkId, + }, + { + select: (data: { lists: ZBookmarkList[] }) => + new Set(data.lists.map((l) => l.id)), + }, + ), ); const { data } = useBookmarkLists(); - const { mutate: addToList } = useAddBookmarkToList({ + const { + mutate: addToList, + isPending: isAddingToList, + variables: addVariables, + } = useAddBookmarkToList({ onSuccess: () => { toast({ message: `The bookmark has been added to the list!`, @@ -46,7 +56,11 @@ const ListPickerPage = () => { onError, }); - const { mutate: removeToList } = useRemoveBookmarkFromList({ + const { + mutate: removeToList, + isPending: isRemovingFromList, + variables: removeVariables, + } = useRemoveBookmarkFromList({ onSuccess: () => { toast({ message: `The bookmark has been removed from the list!`, @@ -67,6 +81,13 @@ const ListPickerPage = () => { } }; + const isListLoading = (listId: string) => { + return ( + (isAddingToList && addVariables?.listId === listId) || + (isRemovingFromList && removeVariables?.listId === listId) + ); + }; + const { allPaths } = data ?? {}; // Filter out lists where user is a viewer (can't add/remove bookmarks) const filteredPaths = allPaths?.filter( @@ -77,30 +98,41 @@ const ListPickerPage = () => { <FlatList className="h-full" contentContainerStyle={{ - gap: 5, + gap: 6, + }} + renderItem={(l) => { + const listId = l.item[l.item.length - 1].id; + const isLoading = isListLoading(listId); + const isChecked = existingLists && existingLists.has(listId); + + return ( + <View className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"> + <Pressable + key={listId} + onPress={() => !isLoading && toggleList(listId)} + disabled={isLoading} + className="flex w-full flex-row items-center justify-between" + > + <Text className="shrink"> + {l.item + .map((item) => `${item.icon} ${item.name}`) + .join(" / ")} + </Text> + {isLoading ? ( + <ActivityIndicator size="small" /> + ) : ( + <Checkbox + value={isChecked} + onValueChange={() => { + toggleList(listId); + }} + disabled={isLoading} + /> + )} + </Pressable> + </View> + ); }} - renderItem={(l) => ( - <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2"> - <Pressable - key={l.item[l.item.length - 1].id} - onPress={() => toggleList(l.item[l.item.length - 1].id)} - className="flex w-full flex-row justify-between" - > - <Text> - {l.item.map((item) => `${item.icon} ${item.name}`).join(" / ")} - </Text> - <Checkbox - value={ - existingLists && - existingLists.has(l.item[l.item.length - 1].id) - } - onValueChange={() => { - toggleList(l.item[l.item.length - 1].id); - }} - /> - </Pressable> - </View> - )} data={filteredPaths} /> </CustomSafeAreaView> diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx index a4575b27..64d057f2 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx @@ -6,17 +6,19 @@ import FullPageSpinner from "@/components/ui/FullPageSpinner"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; import { useColorScheme } from "@/lib/useColorScheme"; +import { useQuery } from "@tanstack/react-query"; import { Check, Plus } from "lucide-react-native"; import { useAutoRefreshingBookmarkQuery, useUpdateBookmarkTags, } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; const NEW_TAG_ID = "new-tag"; const ListPickerPage = () => { + const api = useTRPC(); const { colors } = useColorScheme(); const { slug: bookmarkId } = useLocalSearchParams(); @@ -34,22 +36,24 @@ const ListPickerPage = () => { }); }; - const { data: allTags, isPending: isAllTagsPending } = api.tags.list.useQuery( - {}, - { - select: React.useCallback( - (data: { tags: { id: string; name: string }[] }) => { - return data.tags - .map((t) => ({ - id: t.id, - name: t.name, - lowered: t.name.toLowerCase(), - })) - .sort((a, b) => a.lowered.localeCompare(b.lowered)); - }, - [], - ), - }, + const { data: allTags, isPending: isAllTagsPending } = useQuery( + api.tags.list.queryOptions( + {}, + { + select: React.useCallback( + (data: { tags: { id: string; name: string }[] }) => { + return data.tags + .map((t) => ({ + id: t.id, + name: t.name, + lowered: t.name.toLowerCase(), + })) + .sort((a, b) => a.lowered.localeCompare(b.lowered)); + }, + [], + ), + }, + ), ); const { data: existingTags } = useAutoRefreshingBookmarkQuery({ bookmarkId, @@ -165,7 +169,7 @@ const ListPickerPage = () => { contentInsetAdjustmentBehavior="automatic" keyExtractor={(t) => t.id} contentContainerStyle={{ - gap: 5, + gap: 6, }} SectionSeparatorComponent={() => <View className="h-1" />} sections={[ @@ -207,7 +211,7 @@ const ListPickerPage = () => { }) } > - <View className="mx-2 flex flex-row items-center gap-2 rounded-xl border border-input bg-card px-4 py-2"> + <View className="mx-2 flex flex-row items-center gap-2 rounded-xl bg-card px-4 py-2"> {t.section.title == "Existing Tags" && ( <Check color={colors.foreground} /> )} diff --git a/apps/mobile/app/dashboard/bookmarks/new.tsx b/apps/mobile/app/dashboard/bookmarks/new.tsx index 25882d7f..f7be22e1 100644 --- a/apps/mobile/app/dashboard/bookmarks/new.tsx +++ b/apps/mobile/app/dashboard/bookmarks/new.tsx @@ -2,7 +2,6 @@ import React, { useState } from "react"; import { View } from "react-native"; import { router } from "expo-router"; import { Button } from "@/components/ui/Button"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import { Input } from "@/components/ui/Input"; import { Text } from "@/components/ui/Text"; import { useToast } from "@/components/ui/Toast"; @@ -59,25 +58,23 @@ const NoteEditorPage = () => { }; return ( - <CustomSafeAreaView> - <View className="gap-2 px-4"> - {error && ( - <Text className="w-full text-center text-red-500">{error}</Text> - )} - <Input - onChangeText={setText} - className="bg-card" - multiline - placeholder="What's on your mind?" - autoFocus - autoCapitalize={"none"} - textAlignVertical="top" - /> - <Button onPress={onSubmit} disabled={isPending}> - <Text>Save</Text> - </Button> - </View> - </CustomSafeAreaView> + <View className="flex-1 gap-2 px-4 pt-4"> + {error && ( + <Text className="w-full text-center text-red-500">{error}</Text> + )} + <Input + onChangeText={setText} + className="bg-card" + multiline + placeholder="What's on your mind?" + autoFocus + autoCapitalize={"none"} + textAlignVertical="top" + /> + <Button onPress={onSubmit} disabled={isPending}> + <Text>Save</Text> + </Button> + </View> ); }; diff --git a/apps/mobile/app/dashboard/lists/[slug]/edit.tsx b/apps/mobile/app/dashboard/lists/[slug]/edit.tsx new file mode 100644 index 00000000..c1103b4d --- /dev/null +++ b/apps/mobile/app/dashboard/lists/[slug]/edit.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { Button } from "@/components/ui/Button"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { Input } from "@/components/ui/Input"; +import { Text } from "@/components/ui/Text"; +import { useToast } from "@/components/ui/Toast"; +import { useQuery } from "@tanstack/react-query"; + +import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + +const EditListPage = () => { + const { slug: listId } = useLocalSearchParams<{ slug?: string | string[] }>(); + const [text, setText] = useState(""); + const [query, setQuery] = useState(""); + const { toast } = useToast(); + const api = useTRPC(); + const { mutate, isPending: editIsPending } = useEditBookmarkList({ + onSuccess: () => { + dismiss(); + }, + onError: (error) => { + // Extract error message from the error object + let errorMessage = "Something went wrong"; + if (error.data?.zodError) { + errorMessage = Object.values(error.data.zodError.fieldErrors) + .flat() + .join("\n"); + } else if (error.message) { + errorMessage = error.message; + } + toast({ + message: errorMessage, + variant: "destructive", + }); + }, + }); + + if (typeof listId !== "string") { + throw new Error("Unexpected param type"); + } + + const { data: list, isLoading: fetchIsPending } = useQuery( + api.lists.get.queryOptions({ + listId, + }), + ); + + const dismiss = () => { + router.back(); + }; + + useEffect(() => { + if (!list) return; + setText(list.name ?? ""); + setQuery(list.query ?? ""); + }, [list?.id, list?.query, list?.name]); + + const onSubmit = () => { + if (!text.trim()) { + toast({ message: "List name can't be empty", variant: "destructive" }); + return; + } + + if (list?.type === "smart" && !query.trim()) { + toast({ + message: "Smart lists must have a search query", + variant: "destructive", + }); + return; + } + + mutate({ + listId, + name: text.trim(), + query: list?.type === "smart" ? query.trim() : undefined, + }); + }; + + const isPending = fetchIsPending || editIsPending; + + return ( + <CustomSafeAreaView> + {isPending ? ( + <FullPageSpinner /> + ) : ( + <View className="gap-3 px-4"> + {/* List Type Info - not editable */} + <View className="gap-2"> + <Text className="text-sm text-muted-foreground">List Type</Text> + <View className="flex flex-row gap-2"> + <View className="flex-1"> + <Button + variant={list?.type === "manual" ? "primary" : "secondary"} + disabled + > + <Text>Manual</Text> + </Button> + </View> + <View className="flex-1"> + <Button + variant={list?.type === "smart" ? "primary" : "secondary"} + disabled + > + <Text>Smart</Text> + </Button> + </View> + </View> + </View> + + {/* List Name */} + <View className="flex flex-row items-center gap-1"> + <Text className="shrink p-2">{list?.icon || "🚀"}</Text> + <Input + className="flex-1 bg-card" + onChangeText={setText} + value={text} + placeholder="List Name" + autoFocus + autoCapitalize={"none"} + /> + </View> + + {/* Smart List Query Input */} + {list?.type === "smart" && ( + <View className="gap-2"> + <Text className="text-sm text-muted-foreground"> + Search Query + </Text> + <Input + className="bg-card" + onChangeText={setQuery} + value={query} + placeholder="e.g., #important OR list:work" + autoCapitalize={"none"} + /> + <Text className="text-xs italic text-muted-foreground"> + Smart lists automatically show bookmarks matching your search + query + </Text> + </View> + )} + + <Button disabled={isPending} onPress={onSubmit}> + <Text>Save</Text> + </Button> + </View> + )} + </CustomSafeAreaView> + ); +}; + +export default EditListPage; diff --git a/apps/mobile/app/dashboard/lists/[slug].tsx b/apps/mobile/app/dashboard/lists/[slug]/index.tsx index e7aab443..763df65e 100644 --- a/apps/mobile/app/dashboard/lists/[slug].tsx +++ b/apps/mobile/app/dashboard/lists/[slug]/index.tsx @@ -5,14 +5,16 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; import FullPageError from "@/components/FullPageError"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import { api } from "@/lib/trpc"; import { MenuView } from "@react-native-menu/menu"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Ellipsis } from "lucide-react-native"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; export default function ListView() { const { slug } = useLocalSearchParams(); + const api = useTRPC(); if (typeof slug !== "string") { throw new Error("Unexpected param type"); } @@ -20,7 +22,7 @@ export default function ListView() { data: list, error, refetch, - } = api.lists.get.useQuery({ listId: slug }); + } = useQuery(api.lists.get.queryOptions({ listId: slug })); return ( <CustomSafeAreaView> @@ -58,17 +60,22 @@ function ListActionsMenu({ listId: string; role: ZBookmarkList["userRole"]; }) { - const { mutate: deleteList } = api.lists.delete.useMutation({ - onSuccess: () => { - router.replace("/dashboard/lists"); - }, - }); + const api = useTRPC(); + const { mutate: deleteList } = useMutation( + api.lists.delete.mutationOptions({ + onSuccess: () => { + router.replace("/dashboard/lists"); + }, + }), + ); - const { mutate: leaveList } = api.lists.leaveList.useMutation({ - onSuccess: () => { - router.replace("/dashboard/lists"); - }, - }); + const { mutate: leaveList } = useMutation( + api.lists.leaveList.mutationOptions({ + onSuccess: () => { + router.replace("/dashboard/lists"); + }, + }), + ); const handleDelete = () => { Alert.alert("Delete List", "Are you sure you want to delete this list?", [ @@ -96,10 +103,24 @@ function ListActionsMenu({ ]); }; + const handleEdit = () => { + router.push({ + pathname: "/dashboard/lists/[slug]/edit", + params: { slug: listId }, + }); + }; + return ( <MenuView actions={[ { + id: "edit", + title: "Edit List", + attributes: { + hidden: role !== "owner", + }, + }, + { id: "delete", title: "Delete List", attributes: { @@ -122,9 +143,10 @@ function ListActionsMenu({ onPressAction={({ nativeEvent }) => { if (nativeEvent.event === "delete") { handleDelete(); - } - if (nativeEvent.event === "leave") { + } else if (nativeEvent.event === "leave") { handleLeave(); + } else if (nativeEvent.event === "edit") { + handleEdit(); } }} shouldOpenOnLongPress={false} diff --git a/apps/mobile/app/dashboard/lists/new.tsx b/apps/mobile/app/dashboard/lists/new.tsx index af51ed15..bada46f2 100644 --- a/apps/mobile/app/dashboard/lists/new.tsx +++ b/apps/mobile/app/dashboard/lists/new.tsx @@ -66,20 +66,22 @@ const NewListPage = () => { <View className="gap-2"> <Text className="text-sm text-muted-foreground">List Type</Text> <View className="flex flex-row gap-2"> - <Button - variant={listType === "manual" ? "primary" : "secondary"} - onPress={() => setListType("manual")} - className="flex-1" - > - <Text>Manual</Text> - </Button> - <Button - variant={listType === "smart" ? "primary" : "secondary"} - onPress={() => setListType("smart")} - className="flex-1" - > - <Text>Smart</Text> - </Button> + <View className="flex-1"> + <Button + variant={listType === "manual" ? "primary" : "secondary"} + onPress={() => setListType("manual")} + > + <Text>Manual</Text> + </Button> + </View> + <View className="flex-1"> + <Button + variant={listType === "smart" ? "primary" : "secondary"} + onPress={() => setListType("smart")} + > + <Text>Smart</Text> + </Button> + </View> </View> </View> diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx index ab89ce8d..d43f1aef 100644 --- a/apps/mobile/app/dashboard/search.tsx +++ b/apps/mobile/app/dashboard/search.tsx @@ -7,12 +7,16 @@ import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import { SearchInput } from "@/components/ui/SearchInput"; import { Text } from "@/components/ui/Text"; -import { api } from "@/lib/trpc"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { keepPreviousData } from "@tanstack/react-query"; +import { + keepPreviousData, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; const MAX_DISPLAY_SUGGESTIONS = 5; @@ -29,7 +33,12 @@ export default function Search() { removeItem: (k: string) => AsyncStorage.removeItem(k), }); - const onRefresh = api.useUtils().bookmarks.searchBookmarks.invalidate; + const api = useTRPC(); + const queryClient = useQueryClient(); + + const onRefresh = () => { + queryClient.invalidateQueries(api.bookmarks.searchBookmarks.pathFilter()); + }; const { data, @@ -39,14 +48,16 @@ export default function Search() { isFetching, fetchNextPage, isFetchingNextPage, - } = api.bookmarks.searchBookmarks.useInfiniteQuery( - { text: query }, - { - placeholderData: keepPreviousData, - gcTime: 0, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + } = useInfiniteQuery( + api.bookmarks.searchBookmarks.infiniteQueryOptions( + { text: query }, + { + placeholderData: keepPreviousData, + gcTime: 0, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const filteredHistory = useMemo(() => { diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx new file mode 100644 index 00000000..30ad54b9 --- /dev/null +++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; +import { + ReaderPreview, + ReaderPreviewRef, +} from "@/components/reader/ReaderPreview"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { Check, RotateCcw } from "lucide-react-native"; + +import { + formatFontFamily, + formatFontSize, + formatLineHeight, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +export default function ReaderSettingsPage() { + const { isDarkColorScheme: isDark } = useColorScheme(); + + const { + settings, + localOverrides, + hasLocalOverrides, + hasServerDefaults, + updateLocal, + clearAllLocal, + saveAsDefault, + clearAllDefaults, + } = useReaderSettings(); + + const { + fontSize: effectiveFontSize, + lineHeight: effectiveLineHeight, + fontFamily: effectiveFontFamily, + } = settings; + + // Shared values for sliders + const fontSizeProgress = useSharedValue<number>(effectiveFontSize); + const fontSizeMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.min, + ); + const fontSizeMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.max, + ); + + const lineHeightProgress = useSharedValue<number>(effectiveLineHeight); + const lineHeightMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.min, + ); + const lineHeightMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.max, + ); + + // Display values for showing rounded values while dragging + const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize); + const [displayLineHeight, setDisplayLineHeight] = + useState(effectiveLineHeight); + + // Refs to track latest display values (avoids stale closures in callbacks) + const displayFontSizeRef = useRef(displayFontSize); + displayFontSizeRef.current = displayFontSize; + const displayLineHeightRef = useRef(displayLineHeight); + displayLineHeightRef.current = displayLineHeight; + + // Ref for the WebView preview component + const previewRef = useRef<ReaderPreviewRef>(null); + + // Functions to update preview styles via IPC (called from worklets via runOnJS) + const updatePreviewFontSize = useCallback( + (fontSize: number) => { + setDisplayFontSize(fontSize); + previewRef.current?.updateStyles( + effectiveFontFamily, + fontSize, + displayLineHeightRef.current, + ); + }, + [effectiveFontFamily], + ); + + const updatePreviewLineHeight = useCallback( + (lineHeight: number) => { + setDisplayLineHeight(lineHeight); + previewRef.current?.updateStyles( + effectiveFontFamily, + displayFontSizeRef.current, + lineHeight, + ); + }, + [effectiveFontFamily], + ); + + // Sync slider progress and display values with effective settings + useEffect(() => { + fontSizeProgress.value = effectiveFontSize; + setDisplayFontSize(effectiveFontSize); + }, [effectiveFontSize]); + + useEffect(() => { + lineHeightProgress.value = effectiveLineHeight; + setDisplayLineHeight(effectiveLineHeight); + }, [effectiveLineHeight]); + + const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => { + updateLocal({ fontFamily }); + // Update preview immediately with new font family + previewRef.current?.updateStyles( + fontFamily, + displayFontSize, + displayLineHeight, + ); + }; + + const handleFontSizeChange = (value: number) => { + updateLocal({ fontSize: Math.round(value) }); + }; + + const handleLineHeightChange = (value: number) => { + updateLocal({ lineHeight: Math.round(value * 10) / 10 }); + }; + + const handleSaveAsDefault = () => { + saveAsDefault(); + // Note: clearAllLocal is called automatically in the shared hook's onSuccess + }; + + const handleClearLocalOverrides = () => { + clearAllLocal(); + }; + + const handleClearServerDefaults = () => { + clearAllDefaults(); + }; + + const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"]; + + return ( + <CustomSafeAreaView> + <ScrollView + className="w-full" + contentContainerClassName="items-center gap-4 px-4 py-2" + > + {/* Font Family Selection */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Family + {localOverrides.fontFamily !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="w-full rounded-lg bg-card px-4 py-2"> + {fontFamilyOptions.map((fontFamily, index) => { + const isChecked = effectiveFontFamily === fontFamily; + return ( + <View key={fontFamily}> + <Pressable + onPress={() => handleFontFamilyChange(fontFamily)} + className="flex flex-row items-center justify-between py-2" + > + <Text + style={{ + fontFamily: MOBILE_FONT_FAMILIES[fontFamily], + }} + > + {formatFontFamily(fontFamily)} + </Text> + {isChecked && <Check color="rgb(0, 122, 255)" />} + </Pressable> + {index < fontFamilyOptions.length - 1 && ( + <Divider orientation="horizontal" className="h-0.5" /> + )} + </View> + ); + })} + </View> + </View> + + {/* Font Size */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Size ({formatFontSize(displayFontSize)}) + {localOverrides.fontSize !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.min} + </Text> + <View className="flex-1"> + <Slider + progress={fontSizeProgress} + minimumValue={fontSizeMin} + maximumValue={fontSizeMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(updatePreviewFontSize)(Math.round(value)); + }} + onSlidingComplete={(value) => + handleFontSizeChange(Math.round(value)) + } + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.max} + </Text> + </View> + </View> + + {/* Line Height */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Line Height ({formatLineHeight(displayLineHeight)}) + {localOverrides.lineHeight !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.min} + </Text> + <View className="flex-1"> + <Slider + progress={lineHeightProgress} + minimumValue={lineHeightMin} + maximumValue={lineHeightMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(updatePreviewLineHeight)(Math.round(value * 10) / 10); + }} + onSlidingComplete={handleLineHeightChange} + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.max} + </Text> + </View> + </View> + + {/* Preview */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Preview + </Text> + <ReaderPreview + ref={previewRef} + initialFontFamily={effectiveFontFamily} + initialFontSize={effectiveFontSize} + initialLineHeight={effectiveLineHeight} + /> + </View> + + <Divider orientation="horizontal" className="my-2 w-full" /> + + {/* Save as Default */} + <Pressable + onPress={handleSaveAsDefault} + disabled={!hasLocalOverrides} + className="w-full rounded-lg bg-card px-4 py-3" + > + <Text + className={`text-center ${hasLocalOverrides ? "text-blue-500" : "text-muted-foreground"}`} + > + Save as Default (All Devices) + </Text> + </Pressable> + + {/* Clear Local */} + {hasLocalOverrides && ( + <Pressable + onPress={handleClearLocalOverrides} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Local Overrides</Text> + </Pressable> + )} + + {/* Clear Server */} + {hasServerDefaults && ( + <Pressable + onPress={handleClearServerDefaults} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Server Defaults</Text> + </Pressable> + )} + </ScrollView> + </CustomSafeAreaView> + ); +} diff --git a/apps/mobile/app/dashboard/tags/[slug].tsx b/apps/mobile/app/dashboard/tags/[slug].tsx index 3f294328..328c65d0 100644 --- a/apps/mobile/app/dashboard/tags/[slug].tsx +++ b/apps/mobile/app/dashboard/tags/[slug].tsx @@ -4,15 +4,22 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList"; import FullPageError from "@/components/FullPageError"; import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function TagView() { const { slug } = useLocalSearchParams(); + const api = useTRPC(); if (typeof slug !== "string") { throw new Error("Unexpected param type"); } - const { data: tag, error, refetch } = api.tags.get.useQuery({ tagId: slug }); + const { + data: tag, + error, + refetch, + } = useQuery(api.tags.get.queryOptions({ tagId: slug })); return ( <CustomSafeAreaView> |
