diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-23 11:07:43 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-23 11:07:43 +0000 |
| commit | ad66f78dc9ccd2c6c8f0e67ac8a6c33519db5ce7 (patch) | |
| tree | 3187125a1cb944864e43d11f84dbf115e669e25a /apps/mobile/app | |
| parent | de5ebbc4422b458c685a653b8b8fbaac6e6af5f4 (diff) | |
| download | karakeep-ad66f78dc9ccd2c6c8f0e67ac8a6c33519db5ce7.tar.zst | |
feat(mobile): Add tags screen to mobile app (#2163)
* feat: Add tags screen to mobile app
Add a new Tags tab to the mobile app that displays all tags sorted by usage.
The screen includes:
- Paginated tag list with infinite scroll
- Display of tag names and bookmark counts
- Pull-to-refresh functionality
- Navigation to individual tag detail screens
- Empty state and loading indicators
This brings tag browsing functionality to the mobile app, similar to the
existing Lists tab.
* feat: Add search functionality to mobile tags screen
Add a search input to the tags screen that allows users to filter tags
by name. The search includes:
- Debounced search input (300ms delay) to reduce API calls
- Real-time filtering as the user types
- Sort by relevance when searching, by usage when not searching
- Smooth animated clear button
This enhances the tags browsing experience by making it easy to find
specific tags in a large collection.
* format
---------
Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'apps/mobile/app')
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/_layout.tsx | 9 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/tags.tsx | 140 |
2 files changed, 148 insertions, 1 deletions
diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx index 7419c348..316eddcf 100644 --- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx +++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx @@ -2,7 +2,7 @@ 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 } from "lucide-react-native"; +import { ClipboardList, Home, Settings, Tag } from "lucide-react-native"; export default function TabLayout() { const { colors } = useColorScheme(); @@ -38,6 +38,13 @@ export default function TabLayout() { }} /> <Tabs.Screen + name="tags" + options={{ + title: "Tags", + tabBarIcon: ({ color }) => <Tag color={color} />, + }} + /> + <Tabs.Screen name="settings" options={{ title: "Settings", diff --git a/apps/mobile/app/dashboard/(tabs)/tags.tsx b/apps/mobile/app/dashboard/(tabs)/tags.tsx new file mode 100644 index 00000000..7f3e4ac7 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/tags.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 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> + ); +} |
