aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-23 12:22:34 +0000
committerGitHub <noreply@github.com>2025-11-23 12:22:34 +0000
commit8a5a109cdf14b6503b6bd07aa667788924a12fe6 (patch)
tree2d43fc8f3f9ba7f262e6a0135868194dc3b149a2 /apps
parent7f555f574a0915c6302fb5ad7982a4529fe8a335 (diff)
downloadkarakeep-8a5a109cdf14b6503b6bd07aa667788924a12fe6.tar.zst
feat(mobile): Add highlights page to mobile app (#2156)
* feat: Add highlights page to mobile app This commit adds a new highlights page to the mobile app where users can view all their highlights with the following features: - HighlightCard component: Displays individual highlights with colored borders, text, optional notes, timestamps, and a link to the source bookmark - HighlightList component: Renders a scrollable list of highlights with pull-to-refresh and infinite scroll pagination - UpdatingHighlightList component: Handles data fetching using tRPC infinite queries with automatic cache invalidation - New /dashboard/highlights route with large header title - Added navigation link in Settings tab under "App Settings" All components follow the existing mobile app patterns and integrate with the existing highlights API. * make it a tab --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx15
-rw-r--r--apps/mobile/app/dashboard/(tabs)/highlights.tsx56
-rw-r--r--apps/mobile/components/highlights/HighlightCard.tsx141
-rw-r--r--apps/mobile/components/highlights/HighlightList.tsx65
4 files changed, 276 insertions, 1 deletions
diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
index 316eddcf..5cc6aa92 100644
--- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
@@ -2,7 +2,13 @@ import React, { useLayoutEffect } from "react";
import { Tabs, useNavigation } from "expo-router";
import { StyledTabs } from "@/components/navigation/tabs";
import { useColorScheme } from "@/lib/useColorScheme";
-import { ClipboardList, Home, Settings, Tag } from "lucide-react-native";
+import {
+ ClipboardList,
+ Highlighter,
+ Home,
+ Settings,
+ Tag,
+} from "lucide-react-native";
export default function TabLayout() {
const { colors } = useColorScheme();
@@ -45,6 +51,13 @@ export default function TabLayout() {
}}
/>
<Tabs.Screen
+ name="highlights"
+ options={{
+ title: "Highlights",
+ tabBarIcon: ({ color }) => <Highlighter color={color} />,
+ }}
+ />
+ <Tabs.Screen
name="settings"
options={{
title: "Settings",
diff --git a/apps/mobile/app/dashboard/(tabs)/highlights.tsx b/apps/mobile/app/dashboard/(tabs)/highlights.tsx
new file mode 100644
index 00000000..7879081b
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/highlights.tsx
@@ -0,0 +1,56 @@
+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/components/highlights/HighlightCard.tsx b/apps/mobile/components/highlights/HighlightCard.tsx
new file mode 100644
index 00000000..7e0b4a2b
--- /dev/null
+++ b/apps/mobile/components/highlights/HighlightCard.tsx
@@ -0,0 +1,141 @@
+import { ActivityIndicator, Alert, Pressable, View } from "react-native";
+import * as Haptics from "expo-haptics";
+import { useRouter } from "expo-router";
+import { Text } from "@/components/ui/Text";
+import { api } from "@/lib/trpc";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+import { ExternalLink, Trash2 } from "lucide-react-native";
+
+import type { ZHighlight } from "@karakeep/shared/types/highlights";
+import { useDeleteHighlight } from "@karakeep/shared-react/hooks/highlights";
+
+import { useToast } from "../ui/Toast";
+
+dayjs.extend(relativeTime);
+
+// Color map for highlights (mapped to Tailwind CSS classes used in NativeWind)
+const HIGHLIGHT_COLOR_MAP = {
+ red: "#fecaca", // bg-red-200
+ green: "#bbf7d0", // bg-green-200
+ blue: "#bfdbfe", // bg-blue-200
+ yellow: "#fef08a", // bg-yellow-200
+} as const;
+
+export default function HighlightCard({
+ highlight,
+}: {
+ highlight: ZHighlight;
+}) {
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const onError = () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ };
+
+ const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight(
+ {
+ onSuccess: () => {
+ toast({
+ message: "Highlight has been deleted!",
+ showProgress: false,
+ });
+ },
+ onError,
+ },
+ );
+
+ const deleteHighlightAlert = () =>
+ Alert.alert(
+ "Delete highlight?",
+ "Are you sure you want to delete this highlight?",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ onPress: () => deleteHighlight({ highlightId: highlight.id }),
+ style: "destructive",
+ },
+ ],
+ );
+
+ const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
+ {
+ bookmarkId: highlight.bookmarkId,
+ },
+ {
+ retry: false,
+ },
+ );
+
+ const handleBookmarkPress = () => {
+ Haptics.selectionAsync();
+ router.push(`/dashboard/bookmarks/${highlight.bookmarkId}`);
+ };
+
+ return (
+ <View className="overflow-hidden rounded-xl bg-card p-4">
+ <View className="flex gap-3">
+ {/* Highlight text with colored border */}
+ <View
+ className="rounded-r-lg border-l-4 bg-muted/30 p-3"
+ style={{ borderLeftColor: HIGHLIGHT_COLOR_MAP[highlight.color] }}
+ >
+ <Text className="italic text-foreground">
+ {highlight.text || "No text available"}
+ </Text>
+ </View>
+
+ {/* Note if present */}
+ {highlight.note && (
+ <View className="rounded-lg bg-muted/50 p-2">
+ <Text className="text-sm text-muted-foreground">
+ Note: {highlight.note}
+ </Text>
+ </View>
+ )}
+
+ {/* Footer with timestamp and actions */}
+ <View className="flex flex-row items-center justify-between">
+ <View className="flex flex-row items-center gap-2">
+ <Text className="text-xs text-muted-foreground">
+ {dayjs(highlight.createdAt).fromNow()}
+ </Text>
+ {bookmark && (
+ <>
+ <Text className="text-xs text-muted-foreground">•</Text>
+ <Pressable
+ onPress={handleBookmarkPress}
+ className="flex flex-row items-center gap-1"
+ >
+ <ExternalLink size={12} color="gray" />
+ <Text className="text-xs text-muted-foreground">Source</Text>
+ </Pressable>
+ </>
+ )}
+ </View>
+
+ <View className="flex flex-row gap-2">
+ {isDeleting ? (
+ <ActivityIndicator size="small" />
+ ) : (
+ <Pressable
+ onPress={() => {
+ Haptics.selectionAsync();
+ deleteHighlightAlert();
+ }}
+ >
+ <Trash2 size={18} color="#ef4444" />
+ </Pressable>
+ )}
+ </View>
+ </View>
+ </View>
+ </View>
+ );
+}
diff --git a/apps/mobile/components/highlights/HighlightList.tsx b/apps/mobile/components/highlights/HighlightList.tsx
new file mode 100644
index 00000000..865add2a
--- /dev/null
+++ b/apps/mobile/components/highlights/HighlightList.tsx
@@ -0,0 +1,65 @@
+import { useRef } from "react";
+import { ActivityIndicator, Keyboard, View } from "react-native";
+import Animated, { LinearTransition } from "react-native-reanimated";
+import { Text } from "@/components/ui/Text";
+import { useScrollToTop } from "@react-navigation/native";
+
+import type { ZHighlight } from "@karakeep/shared/types/highlights";
+
+import HighlightCard from "./HighlightCard";
+
+export default function HighlightList({
+ highlights,
+ header,
+ onRefresh,
+ fetchNextPage,
+ isFetchingNextPage,
+ isRefreshing,
+}: {
+ highlights: ZHighlight[];
+ onRefresh: () => void;
+ isRefreshing: boolean;
+ fetchNextPage?: () => void;
+ header?: React.ReactElement;
+ isFetchingNextPage?: boolean;
+}) {
+ const flatListRef = useRef(null);
+ useScrollToTop(flatListRef);
+
+ return (
+ <Animated.FlatList
+ ref={flatListRef}
+ itemLayoutAnimation={LinearTransition}
+ ListHeaderComponent={header}
+ contentContainerStyle={{
+ gap: 15,
+ marginHorizontal: 15,
+ marginBottom: 15,
+ }}
+ renderItem={(h) => <HighlightCard highlight={h.item} />}
+ ListEmptyComponent={
+ <View className="items-center justify-center pt-4">
+ <Text variant="title3">No Highlights</Text>
+ <Text className="mt-2 text-center text-muted-foreground">
+ Highlights you create will appear here
+ </Text>
+ </View>
+ }
+ data={highlights}
+ refreshing={isRefreshing}
+ onRefresh={onRefresh}
+ onScrollBeginDrag={Keyboard.dismiss}
+ keyExtractor={(h) => h.id}
+ onEndReached={fetchNextPage}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+ <View className="items-center">
+ <ActivityIndicator />
+ </View>
+ ) : (
+ <View />
+ )
+ }
+ />
+ );
+}