aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx (renamed from apps/mobile/app/dashboard/(tabs)/highlights.tsx)24
-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.tsx (renamed from apps/mobile/app/dashboard/(tabs)/lists.tsx)36
-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.tsx10
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx176
-rw-r--r--apps/mobile/app/dashboard/(tabs)/tags.tsx142
-rw-r--r--apps/mobile/app/dashboard/_layout.tsx6
-rw-r--r--apps/mobile/app/dashboard/bookmarks/new.tsx37
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx9
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx1
-rw-r--r--apps/mobile/components/highlights/HighlightCard.tsx5
-rw-r--r--apps/mobile/components/highlights/HighlightList.tsx1
-rw-r--r--apps/mobile/components/ui/Avatar.tsx29
20 files changed, 575 insertions, 409 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.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx
index 8d6e37a4..48a190c1 100644
--- a/apps/mobile/app/dashboard/(tabs)/highlights.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx
@@ -1,9 +1,6 @@
-import { View } from "react-native";
import FullPageError from "@/components/FullPageError";
import HighlightList from "@/components/highlights/HighlightList";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import PageTitle from "@/components/ui/PageTitle";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@karakeep/shared-react/trpc";
@@ -42,19 +39,12 @@ export default function Highlights() {
};
return (
- <CustomSafeAreaView edges={["top"]}>
- <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>
+ <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 3e49e6f2..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 edges={["top"]}>
+ <>
+ <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.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx
index 3f81a36e..4c98ef2c 100644
--- a/apps/mobile/app/dashboard/(tabs)/lists.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx
@@ -1,12 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { FlatList, Pressable, View } from "react-native";
import * as Haptics from "expo-haptics";
-import { Link, router } from "expo-router";
+import { Link, router, Stack } from "expo-router";
import FullPageError from "@/components/FullPageError";
import ChevronRight from "@/components/ui/ChevronRight";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import PageTitle from "@/components/ui/PageTitle";
import { Text } from "@/components/ui/Text";
import { useColorScheme } from "@/lib/useColorScheme";
import { condProps } from "@/lib/utils";
@@ -186,27 +184,33 @@ export default function Lists() {
});
return (
- <CustomSafeAreaView edges={["top"]}>
- <FlatList
- className="h-full"
- ListHeaderComponent={
- <View className="flex flex-row justify-between">
- <PageTitle title="Lists" />
+ <>
+ <Stack.Screen
+ options={{
+ headerRight: () => (
<HeaderRight
openNewListModal={() => router.push("/dashboard/lists/new")}
/>
- </View>
- }
+ ),
+ }}
+ />
+ <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={condProps({
- condition: l.item.level > 0,
- props: { marginLeft: l.item.level * 20 },
- })}
+ style={{
+ borderCurve: "continuous",
+ ...condProps({
+ condition: l.item.level > 0,
+ props: { marginLeft: l.item.level * 20 },
+ }),
+ }}
>
{hasAnyListsWithChildren && (
<View style={{ width: 32 }}>
@@ -275,6 +279,6 @@ export default function Lists() {
refreshing={refreshing}
onRefresh={onRefresh}
/>
- </CustomSafeAreaView>
+ </>
);
}
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 f3db822e..fd5798b9 100644
--- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
@@ -12,7 +12,7 @@ export default function TabLayout() {
const { colors } = useColorScheme();
return (
<NativeTabs backgroundColor={colors.grey6} minimizeBehavior="onScrollDown">
- <NativeTabs.Trigger name="index">
+ <NativeTabs.Trigger name="(home)">
<Icon
sf="house.fill"
androidSrc={
@@ -22,7 +22,7 @@ export default function TabLayout() {
<Label>Home</Label>
</NativeTabs.Trigger>
- <NativeTabs.Trigger name="lists">
+ <NativeTabs.Trigger name="(lists)">
<Icon
sf="list.clipboard.fill"
androidSrc={
@@ -32,7 +32,7 @@ export default function TabLayout() {
<Label>Lists</Label>
</NativeTabs.Trigger>
- <NativeTabs.Trigger name="tags">
+ <NativeTabs.Trigger name="(tags)">
<Icon
sf="tag.fill"
androidSrc={<VectorIcon family={MaterialCommunityIcons} name="tag" />}
@@ -40,7 +40,7 @@ export default function TabLayout() {
<Label>Tags</Label>
</NativeTabs.Trigger>
- <NativeTabs.Trigger name="highlights">
+ <NativeTabs.Trigger name="(highlights)">
<Icon
sf="highlighter"
androidSrc={
@@ -50,7 +50,7 @@ export default function TabLayout() {
<Label>Highlights</Label>
</NativeTabs.Trigger>
- <NativeTabs.Trigger name="settings">
+ <NativeTabs.Trigger name="(settings)">
<Icon
sf="gearshape.fill"
androidSrc={<VectorIcon family={MaterialCommunityIcons} name="cog" />}
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx
deleted file mode 100644
index ba38d9e6..00000000
--- a/apps/mobile/app/dashboard/(tabs)/settings.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import { useEffect } from "react";
-import { ActivityIndicator, Pressable, Switch, View } from "react-native";
-import { Slider } from "react-native-awesome-slider";
-import { useSharedValue } from "react-native-reanimated";
-import Constants from "expo-constants";
-import { Link } from "expo-router";
-import { UserProfileHeader } from "@/components/settings/UserProfileHeader";
-import { Button } from "@/components/ui/Button";
-import ChevronRight from "@/components/ui/ChevronRight";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import { Divider } from "@/components/ui/Divider";
-import { Text } from "@/components/ui/Text";
-import { useServerVersion } from "@/lib/hooks";
-import { useSession } from "@/lib/session";
-import useAppSettings from "@/lib/settings";
-import { useQuery } from "@tanstack/react-query";
-
-import { useTRPC } from "@karakeep/shared-react/trpc";
-
-export default function Dashboard() {
- const { logout } = useSession();
- const {
- settings,
- setSettings,
- isLoading: isSettingsLoading,
- } = useAppSettings();
- const api = useTRPC();
-
- const imageQuality = useSharedValue(0);
- const imageQualityMin = useSharedValue(0);
- const imageQualityMax = useSharedValue(100);
-
- useEffect(() => {
- imageQuality.value = settings.imageQuality * 100;
- }, [settings]);
-
- const { data, error } = useQuery(api.users.whoami.queryOptions());
- const {
- data: serverVersion,
- isLoading: isServerVersionLoading,
- error: serverVersionError,
- } = useServerVersion();
-
- if (error?.data?.code === "UNAUTHORIZED") {
- logout();
- }
-
- return (
- <CustomSafeAreaView edges={["top"]}>
- <UserProfileHeader
- image={data?.image}
- name={data?.name}
- email={data?.email}
- />
- <View className="flex h-full w-full items-center gap-3 px-4 py-2">
- <View className="w-full rounded-xl bg-card py-2">
- <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>
- <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/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>
-
- <View className="w-full rounded-xl bg-card py-2">
- <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>
- <Button
- androidRootClassName="w-full"
- onPress={logout}
- variant="destructive"
- >
- <Text>Log Out</Text>
- </Button>
- <View className="mt-4 w-full gap-1">
- <Text className="text-center text-sm text-muted-foreground">
- {isSettingsLoading ? "Loading..." : settings.address}
- </Text>
- <Text className="text-center text-sm text-muted-foreground">
- App Version: {Constants.expoConfig?.version ?? "unknown"}
- </Text>
- <Text className="text-center text-sm text-muted-foreground">
- Server Version:{" "}
- {isServerVersionLoading
- ? "Loading..."
- : serverVersionError
- ? "unavailable"
- : (serverVersion ?? "unknown")}
- </Text>
- </View>
- </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 8a629305..00000000
--- a/apps/mobile/app/dashboard/(tabs)/tags.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import { useEffect, useState } from "react";
-import { FlatList, Pressable, View } from "react-native";
-import { Link } from "expo-router";
-import FullPageError from "@/components/FullPageError";
-import ChevronRight from "@/components/ui/ChevronRight";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import PageTitle from "@/components/ui/PageTitle";
-import { SearchInput } from "@/components/ui/SearchInput";
-import { Text } from "@/components/ui/Text";
-import { useQueryClient } from "@tanstack/react-query";
-
-import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags";
-import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
-import { useTRPC } from "@karakeep/shared-react/trpc";
-
-interface TagItem {
- id: string;
- name: string;
- numBookmarks: number;
- href: string;
-}
-
-export default function Tags() {
- const [refreshing, setRefreshing] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const api = useTRPC();
- const queryClient = useQueryClient();
-
- // Debounce search query to avoid too many API calls
- const debouncedSearch = useDebounce(searchQuery, 300);
-
- // Fetch tags sorted by usage (most used first)
- const {
- data,
- isPending,
- error,
- refetch,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = usePaginatedSearchTags({
- limit: 50,
- sortBy: debouncedSearch ? "relevance" : "usage",
- nameContains: debouncedSearch,
- });
-
- useEffect(() => {
- setRefreshing(isPending);
- }, [isPending]);
-
- if (error) {
- return <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 (
- <CustomSafeAreaView edges={["top"]}>
- <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: 6,
- }}
- renderItem={(item) => (
- <View className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2">
- <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
- }
- />
- </CustomSafeAreaView>
- );
-}
diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx
index 60fbc4fc..78fd7c60 100644
--- a/apps/mobile/app/dashboard/_layout.tsx
+++ b/apps/mobile/app/dashboard/_layout.tsx
@@ -70,8 +70,10 @@ export default function Dashboard() {
options={{
headerTitle: "New Bookmark",
headerBackTitle: "Back",
- headerTransparent: true,
- presentation: "modal",
+ headerTransparent: false,
+ presentation: "formSheet",
+ sheetGrabberVisible: true,
+ sheetAllowedDetents: [0.35, 0.7],
}}
/>
<Stack.Screen
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/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx
index cd0ea445..060aada9 100644
--- a/apps/mobile/components/bookmarks/BookmarkCard.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx
@@ -539,5 +539,12 @@ export default function BookmarkCard({
break;
}
- return <View className="overflow-hidden rounded-xl bg-card">{comp}</View>;
+ return (
+ <View
+ className="overflow-hidden rounded-xl bg-card"
+ style={{ borderCurve: "continuous" }}
+ >
+ {comp}
+ </View>
+ );
}
diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx
index adcf12e0..b3ac13e0 100644
--- a/apps/mobile/components/bookmarks/BookmarkList.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkList.tsx
@@ -30,6 +30,7 @@ export default function BookmarkList({
<Animated.FlatList
ref={flatListRef}
itemLayoutAnimation={LinearTransition}
+ contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={header}
contentContainerStyle={{
gap: 15,
diff --git a/apps/mobile/components/highlights/HighlightCard.tsx b/apps/mobile/components/highlights/HighlightCard.tsx
index 31b3c829..ec4278c5 100644
--- a/apps/mobile/components/highlights/HighlightCard.tsx
+++ b/apps/mobile/components/highlights/HighlightCard.tsx
@@ -80,7 +80,10 @@ export default function HighlightCard({
};
return (
- <View className="overflow-hidden rounded-xl bg-card p-4">
+ <View
+ className="overflow-hidden rounded-xl bg-card p-4"
+ style={{ borderCurve: "continuous" }}
+ >
<View className="flex gap-3">
{/* Highlight text with colored border */}
<View
diff --git a/apps/mobile/components/highlights/HighlightList.tsx b/apps/mobile/components/highlights/HighlightList.tsx
index 865add2a..7d7bb1d4 100644
--- a/apps/mobile/components/highlights/HighlightList.tsx
+++ b/apps/mobile/components/highlights/HighlightList.tsx
@@ -30,6 +30,7 @@ export default function HighlightList({
<Animated.FlatList
ref={flatListRef}
itemLayoutAnimation={LinearTransition}
+ contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={header}
contentContainerStyle={{
gap: 15,
diff --git a/apps/mobile/components/ui/Avatar.tsx b/apps/mobile/components/ui/Avatar.tsx
index ed31df23..239eaba8 100644
--- a/apps/mobile/components/ui/Avatar.tsx
+++ b/apps/mobile/components/ui/Avatar.tsx
@@ -13,6 +13,28 @@ interface AvatarProps {
fallbackClassName?: string;
}
+const AVATAR_COLORS = [
+ "#f87171", // red-400
+ "#fb923c", // orange-400
+ "#fbbf24", // amber-400
+ "#a3e635", // lime-400
+ "#34d399", // emerald-400
+ "#22d3ee", // cyan-400
+ "#60a5fa", // blue-400
+ "#818cf8", // indigo-400
+ "#a78bfa", // violet-400
+ "#e879f9", // fuchsia-400
+];
+
+function nameToColor(name: string | null | undefined): string {
+ if (!name) return AVATAR_COLORS[0];
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
+}
+
function isExternalUrl(url: string) {
return url.startsWith("http://") || url.startsWith("https://");
}
@@ -46,22 +68,25 @@ export function Avatar({
}, [name]);
const showFallback = !imageUrl || imageError;
+ const avatarColor = nameToColor(name);
return (
<View
- className={cn("overflow-hidden bg-black", className)}
+ className={cn("overflow-hidden", className)}
style={{
width: size,
height: size,
borderRadius: size / 2,
+ backgroundColor: showFallback ? avatarColor : undefined,
}}
>
{showFallback ? (
<View
className={cn(
- "flex h-full w-full items-center justify-center bg-black",
+ "flex h-full w-full items-center justify-center",
fallbackClassName,
)}
+ style={{ backgroundColor: avatarColor }}
>
<Text
className="text-white"