feat(mobile): add tags page + many improvements

This commit is contained in:
daniel31x13
2025-11-16 10:10:16 -05:00
parent 3ab026aa37
commit eb66d72589
11 changed files with 446 additions and 29 deletions

View File

@@ -5,7 +5,7 @@ import HapticTab from "@/components/HapticTab";
import TabBarBackground from "@/components/ui/TabBarBackground";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Folder, House, Link, Settings } from "lucide-react-native";
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
export default function TabLayout() {
const { colorScheme } = useColorScheme();
@@ -23,11 +23,15 @@ export default function TabLayout() {
borderTopWidth: 0,
elevation: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
paddingLeft: 5,
paddingRight: 5,
},
default: {
borderTopWidth: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
elevation: 0,
paddingLeft: 5,
paddingRight: 5,
},
}),
}}
@@ -37,7 +41,7 @@ export default function TabLayout() {
options={{
title: "Dashboard",
headerShown: false,
tabBarIcon: ({ color }) => <House size={26} color={color} />,
tabBarIcon: ({ color }) => <House size={24} color={color} />,
}}
/>
<Tabs.Screen
@@ -45,7 +49,7 @@ export default function TabLayout() {
options={{
title: "Links",
headerShown: false,
tabBarIcon: ({ color }) => <Link size={26} color={color} />,
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
}}
/>
<Tabs.Screen
@@ -53,7 +57,15 @@ export default function TabLayout() {
options={{
title: "Collections",
headerShown: false,
tabBarIcon: ({ color }) => <Folder size={26} color={color} />,
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
}}
/>
<Tabs.Screen
name="tags"
options={{
title: "Tags",
headerShown: false,
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
}}
/>
<Tabs.Screen
@@ -61,7 +73,7 @@ export default function TabLayout() {
options={{
title: "Settings",
headerShown: false,
tabBarIcon: ({ color }) => <Settings size={26} color={color} />,
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
}}
/>
</Tabs>

View File

@@ -9,12 +9,13 @@ import {
} from "react-native";
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams } from "expo-router";
import React from "react";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useCollections } from "@linkwarden/router/collections";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
@@ -39,6 +40,24 @@ export default function LinksScreen() {
auth
);
const collections = useCollections(auth);
const navigation = useNavigation();
useEffect(() => {
const activeCollection = collections.data?.filter(
(e) => e.id === Number(id)
)[0];
if (activeCollection?.name)
navigation?.setOptions?.({
headerTitle: activeCollection?.name,
headerSearchBarOptions: {
placeholder: `Search ${activeCollection.name}`,
},
});
}, [navigation]);
return (
<View
style={styles.container}

View File

@@ -18,7 +18,7 @@ export default function Layout() {
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search",
placeholder: "Search Collections",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({

View File

@@ -10,7 +10,7 @@ import {
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { useCollections } from "@linkwarden/router/collections";
import Spinner from "@/components/ui/Spinner";
@@ -35,19 +35,28 @@ export default function LinksScreen() {
const navigation = useNavigation();
const collections = useCollections(auth);
const title = useMemo(() => {
if (section === "pinned-links") return "Pinned Links";
if (section === "recent-links") return "Recent Links";
if (section === "collection") {
return (
collections.data?.find((c) => c.id?.toString() === collectionId)
?.name || "Collection"
);
}
return "Links";
}, [section, collections.data, collectionId]);
useEffect(() => {
navigation.setOptions({
headerTitle:
section === "pinned-links"
? "Pinned Links"
: section === "recent-links"
? "Recent Links"
: section === "collection"
? collections.data?.find((c) => c.id?.toString() === collectionId)
?.name || "Collection"
: "Links",
headerTitle: title,
headerSearchBarOptions: {
placeholder: `Search ${title}`,
},
});
}, [section, navigation]);
}, [title, navigation]);
const { links, data } = useLinks(
{

View File

@@ -18,7 +18,7 @@ export default function Layout() {
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search",
placeholder: "Search Links",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({

View File

@@ -0,0 +1,117 @@
import { useLinks } from "@linkwarden/router/links";
import {
View,
StyleSheet,
FlatList,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useTags } from "@linkwarden/router/tags";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
export default function LinksScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search, id } = useLocalSearchParams<{
search?: string;
id: string;
}>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
tagId: Number(id),
},
auth
);
const tags = useTags(auth);
const navigation = useNavigation();
useEffect(() => {
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
if (activeTag?.name)
navigation?.setOptions?.({
headerTitle: activeTag?.name,
headerSearchBarOptions: {
placeholder: `Search ${activeTag.name}`,
},
});
}, [navigation]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
{data.isLoading ? (
<View className="flex justify-center h-full items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
refreshControl={
<Spinner
refreshing={data.isRefetching}
onRefresh={() => data.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={data.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -0,0 +1,44 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Tags",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Tags",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -0,0 +1,92 @@
import {
View,
StyleSheet,
FlatList,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import useAuthStore from "@/store/auth";
import TagListing from "@/components/TagListing";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { useTags } from "@linkwarden/router/tags";
export default function TagsScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const tags = useTags(auth);
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
useEffect(() => {
const filter =
tags.data?.filter((e) =>
e.name.includes(decodeURIComponent(search || ""))
) || [];
setFilteredTags(filter);
}, [search, tags.data]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
{tags.isLoading ? (
<View className="flex justify-center h-full items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={filteredTags}
refreshControl={
<Spinner
refreshing={tags.isRefetching}
onRefresh={() => tags.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={tags.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => <TagListing tag={item} />}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,13 +1,9 @@
import { View, Text, Image, Pressable, Platform, Alert } from "react-native";
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types";
import { formatAvailable } from "@linkwarden/lib/formatStats";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { SheetManager } from "react-native-actions-sheet";
import { cn } from "@linkwarden/lib/utils";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";

View File

@@ -0,0 +1,120 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { TagIncludingLinkCount } from "@linkwarden/types";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { cn } from "@linkwarden/lib/utils";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Hash, Link } from "lucide-react-native";
import { useRemoveTag } from "@linkwarden/router/tags";
type Props = {
tag: TagIncludingLinkCount;
};
const TagListing = ({ tag }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useRemoveTag(auth);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
"bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50"
)}
onLongPress={() => {}}
onPress={() => router.push(`/tags/${tag.id}`)}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View className="w-full">
<View className="w-[90%] flex-col justify-between gap-3">
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
<Hash
size={16}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(tag.name)}
</Text>
</View>
</View>
<View className="flex-row gap-3">
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(tag.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{tag._count?.links}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="delete-tag"
onSelect={() => {
return Alert.alert(
"Delete Tag",
"Are you sure you want to delete this Tag? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteCollection.mutate(tag.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default TagListing;

View File

@@ -111,14 +111,22 @@ const useUpsertTags = () => {
});
};
const useRemoveTag = () => {
const useRemoveTag = (auth?: MobileAuth) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tagId: number) => {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: "DELETE",
});
const response = await fetch(
(auth?.instance ? auth?.instance : "") + `/api/v1/tags/${tagId}`,
{
method: "DELETE",
headers: {
...(auth?.session
? { Authorization: `Bearer ${auth.session}` }
: {}),
},
}
);
const data = await response.json();
if (!response.ok) throw new Error(data.response);