Skip to content

Commit fc401d0

Browse files
committed
feat(mobile): tag management
1 parent 8ca596b commit fc401d0

5 files changed

Lines changed: 377 additions & 66 deletions

File tree

Lines changed: 89 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { useEffect, useState } from "react";
2-
import { FlatList, Pressable, View } from "react-native";
3-
import { Link } from "expo-router";
2+
import {
3+
FlatList,
4+
Platform,
5+
PlatformColor,
6+
Pressable,
7+
View,
8+
} from "react-native";
9+
import * as Haptics from "expo-haptics";
10+
import { Link, router } from "expo-router";
411
import QueryPageState from "@/components/QueryPageState";
512
import ChevronRight from "@/components/ui/ChevronRight";
613
import EmptyState from "@/components/ui/EmptyState";
14+
import { FAB } from "@/components/ui/FAB";
715
import { SearchInput } from "@/components/ui/SearchInput";
816
import { Text } from "@/components/ui/Text";
917
import { useQueryClient } from "@tanstack/react-query";
10-
import { Tag } from "lucide-react-native";
18+
import { Plus, Tag } from "lucide-react-native";
1119

1220
import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags";
1321
import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
@@ -70,68 +78,86 @@ export default function Tags() {
7078
};
7179

7280
return (
73-
<FlatList
74-
className="h-full"
75-
contentInsetAdjustmentBehavior="automatic"
76-
ListHeaderComponent={
77-
<SearchInput
78-
containerClassName="mx-2 mb-2"
79-
placeholder="Search tags..."
80-
value={searchQuery}
81-
onChangeText={setSearchQuery}
82-
/>
83-
}
84-
contentContainerStyle={{
85-
gap: 6,
86-
paddingBottom: 20,
87-
}}
88-
renderItem={(item) => (
89-
<View
90-
className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"
91-
style={{ borderCurve: "continuous" }}
92-
>
93-
<Link
94-
asChild
95-
key={item.item.id}
96-
href={item.item.href}
97-
className="flex-1"
81+
<>
82+
<FlatList
83+
className="h-full"
84+
contentInsetAdjustmentBehavior="automatic"
85+
ListHeaderComponent={
86+
<SearchInput
87+
containerClassName="mx-2 mb-2"
88+
placeholder="Search tags..."
89+
value={searchQuery}
90+
onChangeText={setSearchQuery}
91+
/>
92+
}
93+
contentContainerStyle={{
94+
gap: 6,
95+
paddingBottom: 20,
96+
}}
97+
renderItem={(item) => (
98+
<View
99+
className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"
100+
style={{ borderCurve: "continuous" }}
98101
>
99-
<Pressable className="flex flex-row items-center justify-between">
100-
<View className="flex-1">
101-
<Text className="font-medium">{item.item.name}</Text>
102-
<Text className="text-sm text-muted-foreground">
103-
{item.item.numBookmarks}{" "}
104-
{item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"}
105-
</Text>
106-
</View>
107-
<ChevronRight />
108-
</Pressable>
109-
</Link>
110-
</View>
111-
)}
112-
data={tags}
113-
refreshing={refreshing}
114-
onRefresh={onRefresh}
115-
onEndReached={handleLoadMore}
116-
onEndReachedThreshold={0.5}
117-
ListFooterComponent={
118-
isFetchingNextPage ? (
119-
<View className="py-4">
120-
<Text className="text-center text-muted-foreground">
121-
Loading more...
122-
</Text>
102+
<Link
103+
asChild
104+
key={item.item.id}
105+
href={item.item.href}
106+
className="flex-1"
107+
>
108+
<Pressable className="flex flex-row items-center justify-between">
109+
<View className="flex-1">
110+
<Text className="font-medium">{item.item.name}</Text>
111+
<Text className="text-sm text-muted-foreground">
112+
{item.item.numBookmarks}{" "}
113+
{item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"}
114+
</Text>
115+
</View>
116+
<ChevronRight />
117+
</Pressable>
118+
</Link>
123119
</View>
124-
) : null
125-
}
126-
ListEmptyComponent={
127-
!isPending ? (
128-
<EmptyState
129-
icon={Tag}
130-
title="No Tags"
131-
subtitle="Tags will appear as you organize your bookmarks"
120+
)}
121+
data={tags}
122+
refreshing={refreshing}
123+
onRefresh={onRefresh}
124+
onEndReached={handleLoadMore}
125+
onEndReachedThreshold={0.5}
126+
ListFooterComponent={
127+
isFetchingNextPage ? (
128+
<View className="py-4">
129+
<Text className="text-center text-muted-foreground">
130+
Loading more...
131+
</Text>
132+
</View>
133+
) : null
134+
}
135+
ListEmptyComponent={
136+
!isPending ? (
137+
<EmptyState
138+
icon={Tag}
139+
title="No Tags"
140+
subtitle="Tags will appear as you organize your bookmarks"
141+
/>
142+
) : null
143+
}
144+
/>
145+
<FAB>
146+
<Pressable
147+
accessibilityLabel="Create tag"
148+
accessibilityRole="button"
149+
className="h-full w-full items-center justify-center"
150+
onPress={() => {
151+
void Haptics.selectionAsync();
152+
router.push("/dashboard/tags/new");
153+
}}
154+
>
155+
<Plus
156+
size={24}
157+
color={Platform.OS === "ios" ? PlatformColor("label") : "white"}
132158
/>
133-
) : null
134-
}
135-
/>
159+
</Pressable>
160+
</FAB>
161+
</>
136162
);
137163
}

‎apps/mobile/app/dashboard/_layout.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ export default function Dashboard() {
191191
headerTransparent: false,
192192
}}
193193
/>
194+
<Stack.Screen
195+
name="tags/new"
196+
options={{
197+
...formSheetSurfaceOptions,
198+
headerTitle: "New Tag",
199+
headerBackTitle: "Back",
200+
headerLargeTitle: false,
201+
headerTransparent: false,
202+
presentation: Platform.select({
203+
ios: "formSheet" as const,
204+
default: "modal" as const,
205+
}),
206+
sheetGrabberVisible: true,
207+
}}
208+
/>
209+
<Stack.Screen
210+
name="tags/[slug]/edit"
211+
options={{
212+
...formSheetSurfaceOptions,
213+
headerTitle: "Edit Tag",
214+
headerBackTitle: "Back",
215+
headerLargeTitle: false,
216+
headerTransparent: false,
217+
presentation: Platform.select({
218+
ios: "formSheet" as const,
219+
default: "modal" as const,
220+
}),
221+
sheetGrabberVisible: true,
222+
}}
223+
/>
194224
<Stack.Screen
195225
name="archive"
196226
options={{

‎apps/mobile/app/dashboard/tags/[slug].tsx‎

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
import { Stack, useLocalSearchParams } from "expo-router";
2-
import BookmarkListHeader from "@/components/bookmarks/BookmarkListHeader";
1+
import { Alert, Platform, View } from "react-native";
2+
import * as Haptics from "expo-haptics";
3+
import { router, Stack, useLocalSearchParams } from "expo-router";
4+
import { useBookmarkListLayoutMenu } from "@/components/bookmarks/BookmarkListHeader";
35
import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList";
46
import QueryPageState from "@/components/QueryPageState";
57
import FullPageSpinner from "@/components/ui/FullPageSpinner";
8+
import { useToast } from "@/components/ui/Toast";
69
import { useArchiveFilter } from "@/lib/hooks";
10+
import { useColorScheme } from "@/lib/useColorScheme";
11+
import { useMenuIconColors } from "@/lib/useMenuIconColors";
12+
import { MenuView } from "@react-native-menu/menu";
713
import { useQuery } from "@tanstack/react-query";
14+
import { Ellipsis } from "lucide-react-native";
815

16+
import { useDeleteTag } from "@karakeep/shared-react/hooks/tags";
917
import { useTRPC } from "@karakeep/shared-react/trpc";
1018

1119
export default function TagView() {
@@ -28,7 +36,8 @@ export default function TagView() {
2836
options={{
2937
headerTitle: tag?.name ?? "",
3038
headerBackTitle: "Back",
31-
headerRight: () => <BookmarkListHeader />,
39+
headerRight: () =>
40+
tag ? <TagActionsMenu tagId={tag.id} tagName={tag.name} /> : null,
3241
}}
3342
/>
3443
{!tag ? (
@@ -46,3 +55,87 @@ export default function TagView() {
4655
</>
4756
);
4857
}
58+
59+
function TagActionsMenu({
60+
tagId,
61+
tagName,
62+
}: {
63+
tagId: string;
64+
tagName: string;
65+
}) {
66+
const { colors } = useColorScheme();
67+
const { menuIconColor, destructiveMenuIconColor } = useMenuIconColors();
68+
const { layoutActions, handleLayoutAction } = useBookmarkListLayoutMenu();
69+
const { toast } = useToast();
70+
const { mutate: deleteTag } = useDeleteTag({
71+
onSuccess: () => {
72+
router.dismissTo("/dashboard/(tabs)/(tags)");
73+
toast({ message: `Tag "${tagName}" deleted`, variant: "success" });
74+
},
75+
onError: (error) => {
76+
toast({ message: error.message, variant: "destructive" });
77+
},
78+
});
79+
80+
const handleDelete = () => {
81+
Alert.alert(
82+
"Delete Tag",
83+
`Are you sure you want to delete the tag "${tagName}"?`,
84+
[
85+
{ text: "Cancel", style: "cancel" },
86+
{
87+
text: "Delete",
88+
onPress: () => deleteTag({ tagId }),
89+
style: "destructive",
90+
},
91+
],
92+
);
93+
};
94+
95+
const handleEdit = () => {
96+
router.push({
97+
pathname: "/dashboard/tags/[slug]/edit",
98+
params: { slug: tagId },
99+
});
100+
};
101+
102+
return (
103+
<MenuView
104+
actions={[
105+
{
106+
id: "edit",
107+
title: "Edit Tag",
108+
image: Platform.select({ ios: "square.and.pencil" }),
109+
imageColor: Platform.select({ ios: menuIconColor }),
110+
},
111+
{
112+
id: "delete_tag",
113+
title: "Delete Tag",
114+
attributes: { destructive: true },
115+
image: Platform.select({ ios: "trash" }),
116+
imageColor: Platform.select({ ios: destructiveMenuIconColor }),
117+
},
118+
...layoutActions,
119+
]}
120+
onPressAction={({ nativeEvent }) => {
121+
if (handleLayoutAction(nativeEvent.event)) {
122+
return;
123+
}
124+
125+
if (nativeEvent.event === "delete_tag") {
126+
handleDelete();
127+
} else if (nativeEvent.event === "edit") {
128+
handleEdit();
129+
}
130+
}}
131+
shouldOpenOnLongPress={false}
132+
>
133+
<View className="my-auto">
134+
<Ellipsis
135+
onPress={() => void Haptics.selectionAsync()}
136+
color={colors.foreground}
137+
/>
138+
</View>
139+
</MenuView>
140+
);
141+
}

0 commit comments

Comments
 (0)