aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/app/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile/app/dashboard')
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx50
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(home)/index.tsx (renamed from apps/mobile/app/dashboard/(tabs)/index.tsx)53
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx284
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx225
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx140
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx117
-rw-r--r--apps/mobile/app/dashboard/(tabs)/highlights.tsx56
-rw-r--r--apps/mobile/app/dashboard/(tabs)/lists.tsx179
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx135
-rw-r--r--apps/mobile/app/dashboard/(tabs)/tags.tsx140
-rw-r--r--apps/mobile/app/dashboard/_layout.tsx23
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx41
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx6
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx100
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx42
-rw-r--r--apps/mobile/app/dashboard/bookmarks/new.tsx37
-rw-r--r--apps/mobile/app/dashboard/lists/[slug]/edit.tsx156
-rw-r--r--apps/mobile/app/dashboard/lists/[slug]/index.tsx (renamed from apps/mobile/app/dashboard/lists/[slug].tsx)50
-rw-r--r--apps/mobile/app/dashboard/lists/new.tsx30
-rw-r--r--apps/mobile/app/dashboard/search.tsx33
-rw-r--r--apps/mobile/app/dashboard/settings/reader-settings.tsx301
-rw-r--r--apps/mobile/app/dashboard/tags/[slug].tsx11
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>