diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-13 21:43:44 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2024-03-14 16:40:45 +0000 |
| commit | 04572a8e5081b1e4871e273cde9dbaaa44c52fe0 (patch) | |
| tree | 8e993acb732a50d1306d4d6953df96c165c57f57 /apps/mobile/components | |
| parent | 2df08ed08c065e8b91bc8df0266bd4bcbb062be4 (diff) | |
| download | karakeep-04572a8e5081b1e4871e273cde9dbaaa44c52fe0.tar.zst | |
structure: Create apps dir and copy tooling dir from t3-turbo repo
Diffstat (limited to 'apps/mobile/components')
| -rw-r--r-- | apps/mobile/components/Logo.tsx | 11 | ||||
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkCard.tsx | 243 | ||||
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkList.tsx | 61 | ||||
| -rw-r--r-- | apps/mobile/components/ui/ActionButton.tsx | 21 | ||||
| -rw-r--r-- | apps/mobile/components/ui/Button.tsx | 81 | ||||
| -rw-r--r-- | apps/mobile/components/ui/Divider.tsx | 28 | ||||
| -rw-r--r-- | apps/mobile/components/ui/FullPageSpinner.tsx | 9 | ||||
| -rw-r--r-- | apps/mobile/components/ui/Input.tsx | 28 | ||||
| -rw-r--r-- | apps/mobile/components/ui/Skeleton.tsx | 38 | ||||
| -rw-r--r-- | apps/mobile/components/ui/Toast.tsx | 183 |
10 files changed, 703 insertions, 0 deletions
diff --git a/apps/mobile/components/Logo.tsx b/apps/mobile/components/Logo.tsx new file mode 100644 index 00000000..57f7a5c3 --- /dev/null +++ b/apps/mobile/components/Logo.tsx @@ -0,0 +1,11 @@ +import { PackageOpen } from "lucide-react-native"; +import { View, Text } from "react-native"; + +export default function Logo() { + return ( + <View className="flex flex-row items-center justify-center gap-2 "> + <PackageOpen color="black" size={70} /> + <Text className="text-5xl">Hoarder</Text> + </View> + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx new file mode 100644 index 00000000..25947790 --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -0,0 +1,243 @@ +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import * as WebBrowser from "expo-web-browser"; +import { Star, Archive, Trash, ArchiveRestore } from "lucide-react-native"; +import { View, Text, Image, ScrollView, Pressable } from "react-native"; +import Markdown from "react-native-markdown-display"; + +import { ActionButton } from "../ui/ActionButton"; +import { Divider } from "../ui/Divider"; +import { Skeleton } from "../ui/Skeleton"; +import { useToast } from "../ui/Toast"; + +import { api } from "@/lib/trpc"; + +const MAX_LOADING_MSEC = 30 * 1000; + +export function isBookmarkStillCrawling(bookmark: ZBookmark) { + return ( + bookmark.content.type === "link" && + !bookmark.content.crawledAt && + Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC + ); +} + +export function isBookmarkStillTagging(bookmark: ZBookmark) { + return ( + bookmark.taggingStatus === "pending" && + Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC + ); +} + +export function isBookmarkStillLoading(bookmark: ZBookmark) { + return isBookmarkStillTagging(bookmark) || isBookmarkStillCrawling(bookmark); +} + +function ActionBar({ bookmark }: { bookmark: ZBookmark }) { + const { toast } = useToast(); + const apiUtils = api.useUtils(); + + const { mutate: deleteBookmark, isPending: isDeletionPending } = + api.bookmarks.deleteBookmark.useMutation({ + onSuccess: () => { + apiUtils.bookmarks.getBookmarks.invalidate(); + }, + onError: () => { + toast({ + message: "Something went wrong", + variant: "destructive", + showProgress: false, + }); + }, + }); + const { + mutate: updateBookmark, + variables, + isPending: isUpdatePending, + } = api.bookmarks.updateBookmark.useMutation({ + onSuccess: () => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: bookmark.id }); + }, + onError: () => { + toast({ + message: "Something went wrong", + variant: "destructive", + showProgress: false, + }); + }, + }); + + return ( + <View className="flex flex-row gap-4"> + <Pressable + onPress={() => + updateBookmark({ + bookmarkId: bookmark.id, + favourited: !bookmark.favourited, + }) + } + > + {(variables ? variables.favourited : bookmark.favourited) ? ( + <Star fill="#ebb434" color="#ebb434" /> + ) : ( + <Star color="gray" /> + )} + </Pressable> + <ActionButton + loading={isUpdatePending} + onPress={() => + updateBookmark({ + bookmarkId: bookmark.id, + archived: !bookmark.archived, + }) + } + > + {bookmark.archived ? ( + <ArchiveRestore color="gray" /> + ) : ( + <Archive color="gray" /> + )} + </ActionButton> + <ActionButton + loading={isDeletionPending} + onPress={() => + deleteBookmark({ + bookmarkId: bookmark.id, + }) + } + > + <Trash color="gray" /> + </ActionButton> + </View> + ); +} + +function TagList({ bookmark }: { bookmark: ZBookmark }) { + const tags = bookmark.tags; + + if (isBookmarkStillTagging(bookmark)) { + return ( + <> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </> + ); + } + + return ( + <ScrollView horizontal showsHorizontalScrollIndicator={false}> + <View className="flex flex-row gap-2"> + {tags.map((t) => ( + <View + key={t.id} + className="rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold" + > + <Text>{t.name}</Text> + </View> + ))} + </View> + </ScrollView> + ); +} + +function LinkCard({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== "link") { + throw new Error("Wrong content type rendered"); + } + + const url = bookmark.content.url; + const parsedUrl = new URL(url); + + const imageComp = bookmark.content.imageUrl ? ( + <Image + source={{ uri: bookmark.content.imageUrl }} + className="h-56 min-h-56 w-full rounded-t-lg object-cover" + /> + ) : ( + <Image + source={require("@/assets/blur.jpeg")} + className="h-56 w-full rounded-t-lg" + /> + ); + + return ( + <View className="flex gap-2"> + {imageComp} + <View className="flex gap-2 p-2"> + <Text + className="line-clamp-2 text-xl font-bold" + onPress={() => WebBrowser.openBrowserAsync(url)} + > + {bookmark.content.title || parsedUrl.host} + </Text> + <TagList bookmark={bookmark} /> + <Divider orientation="vertical" className="mt-2 h-0.5 w-full" /> + <View className="mt-2 flex flex-row justify-between px-2 pb-2"> + <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text> + <ActionBar bookmark={bookmark} /> + </View> + </View> + </View> + ); +} + +function TextCard({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== "text") { + throw new Error("Wrong content type rendered"); + } + return ( + <View className="flex max-h-96 gap-2 p-2"> + <View className="max-h-56 overflow-hidden p-2"> + <Markdown>{bookmark.content.text}</Markdown> + </View> + <TagList bookmark={bookmark} /> + <Divider orientation="vertical" className="mt-2 h-0.5 w-full" /> + <View className="flex flex-row justify-between p-2"> + <View /> + <ActionBar bookmark={bookmark} /> + </View> + </View> + ); +} + +export default function BookmarkCard({ + bookmark: initialData, +}: { + bookmark: ZBookmark; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + // If the link is not crawled or not tagged + if (isBookmarkStillLoading(data)) { + return 1000; + } + return false; + }, + }, + ); + + let comp; + switch (bookmark.content.type) { + case "link": + comp = <LinkCard bookmark={bookmark} />; + break; + case "text": + comp = <TextCard bookmark={bookmark} />; + break; + } + + return ( + <View className="w-96 rounded-lg border border-gray-300 bg-white shadow-sm"> + {comp} + </View> + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx new file mode 100644 index 00000000..8e408709 --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkList.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import { Text, View } from "react-native"; +import Animated, { LinearTransition } from "react-native-reanimated"; + +import BookmarkCard from "./BookmarkCard"; +import FullPageSpinner from "../ui/FullPageSpinner"; + +import { api } from "@/lib/trpc"; + +export default function BookmarkList({ + favourited, + archived, + ids, +}: { + favourited?: boolean; + archived?: boolean; + ids?: string[]; +}) { + const apiUtils = api.useUtils(); + const [refreshing, setRefreshing] = useState(false); + const { data, isPending, isPlaceholderData } = + api.bookmarks.getBookmarks.useQuery({ + favourited, + archived, + ids, + }); + + useEffect(() => { + setRefreshing(isPending || isPlaceholderData); + }, [isPending, isPlaceholderData]); + + if (isPending || !data) { + return <FullPageSpinner />; + } + + const onRefresh = () => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate(); + }; + + return ( + <Animated.FlatList + itemLayoutAnimation={LinearTransition} + contentContainerStyle={{ + gap: 15, + marginVertical: 15, + alignItems: "center", + }} + renderItem={(b) => <BookmarkCard bookmark={b.item} />} + ListEmptyComponent={ + <View className="h-full items-center justify-center"> + <Text className="text-xl">No Bookmarks</Text> + </View> + } + data={data.bookmarks} + refreshing={refreshing} + onRefresh={onRefresh} + keyExtractor={(b) => b.id} + /> + ); +} diff --git a/apps/mobile/components/ui/ActionButton.tsx b/apps/mobile/components/ui/ActionButton.tsx new file mode 100644 index 00000000..c51eb332 --- /dev/null +++ b/apps/mobile/components/ui/ActionButton.tsx @@ -0,0 +1,21 @@ +import { ActivityIndicator, Pressable, PressableProps } from "react-native"; + +export function ActionButton({ + children, + loading, + disabled, + ...props +}: PressableProps & { + loading: boolean; +}) { + if (disabled !== undefined) { + disabled ||= loading; + } else if (loading) { + disabled = true; + } + return ( + <Pressable {...props} disabled={disabled}> + {loading ? <ActivityIndicator /> : children} + </Pressable> + ); +} diff --git a/apps/mobile/components/ui/Button.tsx b/apps/mobile/components/ui/Button.tsx new file mode 100644 index 00000000..4c3cbc69 --- /dev/null +++ b/apps/mobile/components/ui/Button.tsx @@ -0,0 +1,81 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import { Text, TouchableOpacity } from "react-native"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "flex flex-row items-center justify-center rounded-md", + { + variants: { + variant: { + default: "bg-primary", + secondary: "bg-secondary", + destructive: "bg-destructive", + ghost: "bg-slate-700", + link: "text-primary underline-offset-4", + }, + size: { + default: "h-10 px-4", + sm: "h-8 px-2", + lg: "h-12 px-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const buttonTextVariants = cva("text-center font-medium", { + variants: { + variant: { + default: "text-primary-foreground", + secondary: "text-secondary-foreground", + destructive: "text-destructive-foreground", + ghost: "text-primary-foreground", + link: "text-primary-foreground underline", + }, + size: { + default: "text-base", + sm: "text-sm", + lg: "text-xl", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, +}); + +interface ButtonProps + extends React.ComponentPropsWithoutRef<typeof TouchableOpacity>, + VariantProps<typeof buttonVariants> { + label: string; + labelClasses?: string; +} +function Button({ + label, + labelClasses, + className, + variant, + size, + ...props +}: ButtonProps) { + return ( + <TouchableOpacity + className={cn(buttonVariants({ variant, size, className }))} + {...props} + > + <Text + className={cn( + buttonTextVariants({ variant, size, className: labelClasses }), + )} + > + {label} + </Text> + </TouchableOpacity> + ); +} + +export { Button, buttonVariants, buttonTextVariants }; diff --git a/apps/mobile/components/ui/Divider.tsx b/apps/mobile/components/ui/Divider.tsx new file mode 100644 index 00000000..1da0a71e --- /dev/null +++ b/apps/mobile/components/ui/Divider.tsx @@ -0,0 +1,28 @@ +import { View } from "react-native"; + +import { cn } from "@/lib/utils"; + +function Divider({ + color = "#DFE4EA", + className, + orientation, + ...props +}: { + color?: string; + orientation: "horizontal" | "vertical"; +} & React.ComponentPropsWithoutRef<typeof View>) { + const dividerStyles = [{ backgroundColor: color }]; + + return ( + <View + className={cn( + orientation === "horizontal" ? "h-0.5" : "w-0.5", + className, + )} + style={dividerStyles} + {...props} + /> + ); +} + +export { Divider }; diff --git a/apps/mobile/components/ui/FullPageSpinner.tsx b/apps/mobile/components/ui/FullPageSpinner.tsx new file mode 100644 index 00000000..01187f11 --- /dev/null +++ b/apps/mobile/components/ui/FullPageSpinner.tsx @@ -0,0 +1,9 @@ +import { View, ActivityIndicator } from "react-native"; + +export default function FullPageSpinner() { + return ( + <View className="h-full w-full items-center justify-center"> + <ActivityIndicator /> + </View> + ); +} diff --git a/apps/mobile/components/ui/Input.tsx b/apps/mobile/components/ui/Input.tsx new file mode 100644 index 00000000..2fcb2764 --- /dev/null +++ b/apps/mobile/components/ui/Input.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; +import { Text, TextInput, View } from "react-native"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.ComponentPropsWithoutRef<typeof TextInput> { + label?: string; + labelClasses?: string; + inputClasses?: string; +} + +const Input = forwardRef<React.ElementRef<typeof TextInput>, InputProps>( + ({ className, label, labelClasses, inputClasses, ...props }, ref) => ( + <View className={cn("flex flex-col gap-1.5", className)}> + {label && <Text className={cn("text-base", labelClasses)}>{label}</Text>} + <TextInput + className={cn( + inputClasses, + "border-input rounded-lg border px-4 py-2.5", + )} + {...props} + /> + </View> + ), +); + +export { Input }; diff --git a/apps/mobile/components/ui/Skeleton.tsx b/apps/mobile/components/ui/Skeleton.tsx new file mode 100644 index 00000000..68b22e1e --- /dev/null +++ b/apps/mobile/components/ui/Skeleton.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; +import { Animated, type View } from "react-native"; + +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: { className?: string } & React.ComponentPropsWithoutRef<typeof View>) { + const fadeAnim = useRef(new Animated.Value(0.5)).current; + + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 0.5, + duration: 1000, + useNativeDriver: true, + }), + ]), + ).start(); + }, [fadeAnim]); + + return ( + <Animated.View + className={cn("bg-muted rounded-md", className)} + style={[{ opacity: fadeAnim }]} + {...props} + /> + ); +} + +export { Skeleton }; diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx new file mode 100644 index 00000000..fb319f84 --- /dev/null +++ b/apps/mobile/components/ui/Toast.tsx @@ -0,0 +1,183 @@ +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { Animated, Text, View } from "react-native"; + +import { cn } from "@/lib/utils"; + +const toastVariants = { + default: "bg-foreground", + destructive: "bg-destructive", + success: "bg-green-500", + info: "bg-blue-500", +}; + +interface ToastProps { + id: number; + message: string; + onHide: (id: number) => void; + variant?: keyof typeof toastVariants; + duration?: number; + showProgress?: boolean; +} +function Toast({ + id, + message, + onHide, + variant = "default", + duration = 3000, + showProgress = true, +}: ToastProps) { + const opacity = useRef(new Animated.Value(0)).current; + const progress = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(progress, { + toValue: 1, + duration: duration - 1000, + useNativeDriver: false, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + ]).start(() => onHide(id)); + }, [duration]); + + return ( + <Animated.View + className={` + ${toastVariants[variant]} + m-2 mb-1 transform rounded-lg p-4 shadow-md transition-all + `} + style={{ + opacity, + transform: [ + { + translateY: opacity.interpolate({ + inputRange: [0, 1], + outputRange: [-20, 0], + }), + }, + ], + }} + > + <Text className="text-background text-left font-semibold">{message}</Text> + {showProgress && ( + <View className="mt-2 rounded"> + <Animated.View + className="h-2 rounded bg-white opacity-30 dark:bg-black" + style={{ + width: progress.interpolate({ + inputRange: [0, 1], + outputRange: ["0%", "100%"], + }), + }} + /> + </View> + )} + </Animated.View> + ); +} + +type ToastVariant = keyof typeof toastVariants; + +interface ToastMessage { + id: number; + text: string; + variant: ToastVariant; + duration?: number; + position?: string; + showProgress?: boolean; +} +interface ToastContextProps { + toast: (t: { + message: string; + variant?: keyof typeof toastVariants; + duration?: number; + position?: "top" | "bottom"; + showProgress?: boolean; + }) => void; + removeToast: (id: number) => void; +} +const ToastContext = createContext<ToastContextProps | undefined>(undefined); + +// TODO: refactor to pass position to Toast instead of ToastProvider +function ToastProvider({ + children, + position = "top", +}: { + children: React.ReactNode; + position?: "top" | "bottom"; +}) { + const [messages, setMessages] = useState<ToastMessage[]>([]); + + const toast: ToastContextProps["toast"] = ({ + message, + variant = "default", + duration = 3000, + position = "top", + showProgress = true, + }: { + message: string; + variant?: ToastVariant; + duration?: number; + position?: "top" | "bottom"; + showProgress?: boolean; + }) => { + setMessages((prev) => [ + ...prev, + { + id: Date.now(), + text: message, + variant, + duration, + position, + showProgress, + }, + ]); + }; + + const removeToast = (id: number) => { + setMessages((prev) => prev.filter((message) => message.id !== id)); + }; + + return ( + <ToastContext.Provider value={{ toast, removeToast }}> + {children} + <View + className={cn("absolute left-0 right-0", { + "top-[45px]": position === "top", + "bottom-0": position === "bottom", + })} + > + {messages.map((message) => ( + <Toast + key={message.id} + id={message.id} + message={message.text} + variant={message.variant} + duration={message.duration} + showProgress={message.showProgress} + onHide={removeToast} + /> + ))} + </View> + </ToastContext.Provider> + ); +} + +function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within ToastProvider"); + } + return context; +} + +export { ToastProvider, ToastVariant, Toast, toastVariants, useToast }; |
