aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/components
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-03-13 21:43:44 +0000
committerMohamed Bassem <me@mbassem.com>2024-03-14 16:40:45 +0000
commit04572a8e5081b1e4871e273cde9dbaaa44c52fe0 (patch)
tree8e993acb732a50d1306d4d6953df96c165c57f57 /apps/mobile/components
parent2df08ed08c065e8b91bc8df0266bd4bcbb062be4 (diff)
downloadkarakeep-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.tsx11
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx243
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx61
-rw-r--r--apps/mobile/components/ui/ActionButton.tsx21
-rw-r--r--apps/mobile/components/ui/Button.tsx81
-rw-r--r--apps/mobile/components/ui/Divider.tsx28
-rw-r--r--apps/mobile/components/ui/FullPageSpinner.tsx9
-rw-r--r--apps/mobile/components/ui/Input.tsx28
-rw-r--r--apps/mobile/components/ui/Skeleton.tsx38
-rw-r--r--apps/mobile/components/ui/Toast.tsx183
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 };