mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 03:47:02 +00:00
feat(mobile): add collection page
This commit is contained in:
@@ -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 { House, Link, Settings } from "lucide-react-native";
|
||||
import { Folder, House, Link, Settings } from "lucide-react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
@@ -48,6 +48,14 @@ export default function TabLayout() {
|
||||
tabBarIcon: ({ color }) => <Link size={26} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="collections"
|
||||
options={{
|
||||
title: "Collections",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Folder size={26} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
|
||||
100
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
100
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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 } from "expo-router";
|
||||
import React from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
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 ?? ""),
|
||||
collectionId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
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: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal 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: "Collections",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search",
|
||||
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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import CollectionListing from "@/components/CollectionListing";
|
||||
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 { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
|
||||
export default function CollectionsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const [filteredCollections, setFilteredCollections] = useState<
|
||||
CollectionIncludingMembersAndLinkCount[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
collections.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredCollections(filter);
|
||||
}, [search, collections.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{collections.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={filteredCollections}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={collections.isRefetching}
|
||||
onRefresh={() => collections.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={collections.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <CollectionListing collection={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: {},
|
||||
}),
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { Platform, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
|
||||
136
apps/mobile/components/CollectionListing.tsx
Normal file
136
apps/mobile/components/CollectionListing.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { View, Text, Image, 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";
|
||||
import { CalendarDays, Folder, Link } from "lucide-react-native";
|
||||
import { useDeleteCollection } from "@linkwarden/router/collections";
|
||||
|
||||
type Props = {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
};
|
||||
|
||||
const CollectionListing = ({ collection }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useDeleteCollection(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(`/collections/${collection.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">
|
||||
<Folder
|
||||
size={16}
|
||||
fill={collection.color || ""}
|
||||
color={collection.color || ""}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(collection.name)}
|
||||
</Text>
|
||||
</View>
|
||||
{collection.description && (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-light text-sm text-base-content"
|
||||
>
|
||||
{decode(collection.description)}
|
||||
</Text>
|
||||
)}
|
||||
</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(collection.createdAt as string).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"
|
||||
>
|
||||
{collection._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-collection"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Collection",
|
||||
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(collection.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionListing;
|
||||
@@ -109,17 +109,23 @@ const useUpdateCollection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const useDeleteCollection = () => {
|
||||
const useDeleteCollection = (auth?: MobileAuth) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(`/api/v1/collections/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const response = await fetch(
|
||||
(auth?.instance ? auth?.instance : "") + `/api/v1/collections/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(auth?.session
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user