diff options
6 files changed, 444 insertions, 379 deletions
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 3b1300ca..7bf0f118 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,384 +1,24 @@ import { useState } from "react"; -import { - Alert, - Keyboard, - Linking, - Pressable, - ScrollView, - View, -} from "react-native"; -import ImageView from "react-native-image-viewing"; -import * as Haptics from "expo-haptics"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import BookmarkAssetImage from "@/components/bookmarks/BookmarkAssetImage"; -import { - BookmarkLinkArchivePreview, - BookmarkLinkBrowserPreview, - BookmarkLinkReaderPreview, - BookmarkLinkScreenshotPreview, -} from "@/components/bookmarks/BookmarkLinkPreview"; -import BookmarkTextMarkdown from "@/components/bookmarks/BookmarkTextMarkdown"; -import { PDFViewer } from "@/components/bookmarks/PDFViewer"; +import { KeyboardAvoidingView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Stack, useLocalSearchParams } from "expo-router"; +import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; +import BookmarkLinkTypeSelector, { + BookmarkLinkType, +} from "@/components/bookmarks/BookmarkLinkTypeSelector"; +import BookmarkLinkView from "@/components/bookmarks/BookmarkLinkView"; +import BookmarkTextView from "@/components/bookmarks/BookmarkTextView"; +import BottomActions from "@/components/bookmarks/BottomActions"; import FullPageError from "@/components/FullPageError"; -import { TailwindResolver } from "@/components/TailwindResolver"; -import { Button } from "@/components/ui/Button"; -import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; -import { Input } from "@/components/ui/Input"; -import { Text } from "@/components/ui/Text"; -import { useToast } from "@/components/ui/Toast"; -import { useAssetUrl } from "@/lib/hooks"; import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; -import { MenuView } from "@react-native-menu/menu"; -import { - ChevronDown, - ClipboardList, - Globe, - Info, - Tag, - Trash2, -} from "lucide-react-native"; import { useColorScheme } from "nativewind"; -import { - useDeleteBookmark, - useUpdateBookmark, -} from "@karakeep/shared-react/hooks/bookmarks"; -import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -type BookmarkLinkType = "browser" | "reader" | "screenshot" | "archive"; - -function getAvailableViewTypes(bookmark: ZBookmark): BookmarkLinkType[] { - if (bookmark.content.type !== BookmarkTypes.LINK) { - return []; - } - - const availableTypes: BookmarkLinkType[] = ["browser", "reader"]; - - if (bookmark.assets.some((asset) => asset.assetType === "screenshot")) { - availableTypes.push("screenshot"); - } - - if ( - bookmark.assets.some( - (asset) => - asset.assetType === "precrawledArchive" || - asset.assetType === "fullPageArchive", - ) - ) { - availableTypes.push("archive"); - } - - return availableTypes; -} - -function BookmarkLinkTypeSelector({ - type, - onChange, - bookmark, -}: { - type: BookmarkLinkType; - onChange: (type: BookmarkLinkType) => void; - bookmark: ZBookmark; -}) { - const availableTypes = getAvailableViewTypes(bookmark); - - const allActions = [ - { - id: "reader" as const, - title: "Reader View", - state: type === "reader" ? ("on" as const) : undefined, - }, - { - id: "browser" as const, - title: "Browser", - state: type === "browser" ? ("on" as const) : undefined, - }, - { - id: "screenshot" as const, - title: "Screenshot", - state: type === "screenshot" ? ("on" as const) : undefined, - }, - { - id: "archive" as const, - title: "Archived Page", - state: type === "archive" ? ("on" as const) : undefined, - }, - ]; - - const availableActions = allActions.filter((action) => - availableTypes.includes(action.id), - ); - - return ( - <MenuView - onPressAction={({ nativeEvent }) => { - Haptics.selectionAsync(); - onChange(nativeEvent.event as BookmarkLinkType); - }} - actions={availableActions} - shouldOpenOnLongPress={false} - > - <ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" /> - </MenuView> - ); -} - -function BottomActions({ bookmark }: { bookmark: ZBookmark }) { - const { toast } = useToast(); - const router = useRouter(); - - const { mutate: deleteBookmark, isPending: isDeletionPending } = - useDeleteBookmark({ - onSuccess: () => { - router.back(); - toast({ - message: "The bookmark has been deleted!", - showProgress: false, - }); - }, - onError: () => { - toast({ - message: "Something went wrong", - variant: "destructive", - showProgress: false, - }); - }, - }); - - const deleteBookmarkAlert = () => - Alert.alert( - "Delete bookmark?", - "Are you sure you want to delete this bookmark?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - onPress: () => deleteBookmark({ bookmarkId: bookmark.id }), - style: "destructive", - }, - ], - ); - - const actions = [ - { - id: "lists", - icon: ( - <TailwindResolver - className="text-foreground" - comp={(styles) => <ClipboardList color={styles?.color?.toString()} />} - /> - ), - shouldRender: true, - onClick: () => - router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`), - disabled: false, - }, - { - id: "tags", - icon: ( - <TailwindResolver - className="text-foreground" - comp={(styles) => <Tag color={styles?.color?.toString()} />} - /> - ), - shouldRender: true, - onClick: () => - router.push(`/dashboard/bookmarks/${bookmark.id}/manage_tags`), - disabled: false, - }, - { - id: "open", - icon: ( - <TailwindResolver - className="text-foreground" - comp={(styles) => <Info color={styles?.color?.toString()} />} - /> - ), - shouldRender: true, - onClick: () => router.push(`/dashboard/bookmarks/${bookmark.id}/info`), - disabled: false, - }, - { - id: "delete", - icon: ( - <TailwindResolver - className="text-foreground" - comp={(styles) => <Trash2 color={styles?.color?.toString()} />} - /> - ), - shouldRender: true, - onClick: deleteBookmarkAlert, - disabled: isDeletionPending, - }, - { - id: "browser", - icon: ( - <TailwindResolver - className="text-foreground" - comp={(styles) => <Globe color={styles?.color?.toString()} />} - /> - ), - shouldRender: bookmark.content.type == BookmarkTypes.LINK, - onClick: () => - bookmark.content.type == BookmarkTypes.LINK && - Linking.openURL(bookmark.content.url), - disabled: false, - }, - ]; - return ( - <View> - <View className="flex flex-row items-center justify-between px-10 pb-2 pt-4"> - {actions.map( - (a) => - a.shouldRender && ( - <Pressable - disabled={a.disabled} - key={a.id} - onPress={a.onClick} - className="py-auto" - > - {a.icon} - </Pressable> - ), - )} - </View> - </View> - ); -} - -function BookmarkLinkView({ - bookmark, - bookmarkPreviewType, -}: { - bookmark: ZBookmark; - bookmarkPreviewType: BookmarkLinkType; -}) { - if (bookmark.content.type !== BookmarkTypes.LINK) { - throw new Error("Wrong content type rendered"); - } - - switch (bookmarkPreviewType) { - case "browser": - return <BookmarkLinkBrowserPreview bookmark={bookmark} />; - case "reader": - return <BookmarkLinkReaderPreview bookmark={bookmark} />; - case "screenshot": - return <BookmarkLinkScreenshotPreview bookmark={bookmark} />; - case "archive": - return <BookmarkLinkArchivePreview bookmark={bookmark} />; - } -} - -function BookmarkTextView({ bookmark }: { bookmark: ZBookmark }) { - if (bookmark.content.type !== BookmarkTypes.TEXT) { - throw new Error("Wrong content type rendered"); - } - const { toast } = useToast(); - - const [isEditing, setIsEditing] = useState(false); - const initialText = bookmark.content.text; - const [content, setContent] = useState(initialText); - - const { mutate, isPending } = useUpdateBookmark({ - onError: () => { - toast({ - message: "Something went wrong", - variant: "destructive", - }); - }, - onSuccess: () => { - setIsEditing(false); - }, - }); - - return ( - <View className="flex-1"> - {isEditing && ( - <View className="absolute right-0 top-0 z-10 m-4 flex flex-row gap-1"> - <Button onPress={Keyboard.dismiss}> - <Text>Save</Text> - </Button> - <Button - onPress={() => { - setContent(initialText); - setIsEditing(false); - }} - > - <Text>Discard</Text> - </Button> - </View> - )} - <ScrollView className="flex bg-background p-2"> - {isEditing ? ( - <Input - loading={isPending} - editable={!isPending} - onBlur={() => - mutate({ - bookmarkId: bookmark.id, - text: content, - }) - } - value={content} - onChangeText={setContent} - multiline - autoFocus - /> - ) : ( - <Pressable onPress={() => setIsEditing(true)}> - <View className="mb-4 rounded-xl border border-accent p-2"> - <BookmarkTextMarkdown text={content} /> - </View> - </Pressable> - )} - </ScrollView> - </View> - ); -} - -function BookmarkAssetView({ bookmark }: { bookmark: ZBookmark }) { - const [imageZoom, setImageZoom] = useState(false); - if (bookmark.content.type !== BookmarkTypes.ASSET) { - throw new Error("Wrong content type rendered"); - } - const assetSource = useAssetUrl(bookmark.content.assetId); - - // Check if this is a PDF asset - if (bookmark.content.assetType === "pdf") { - return ( - <View className="flex flex-1"> - <PDFViewer - source={assetSource.uri ?? ""} - headers={assetSource.headers} - /> - </View> - ); - } - - // Handle image assets as before - return ( - <View className="flex flex-1 gap-2"> - <ImageView - visible={imageZoom} - imageIndex={0} - onRequestClose={() => setImageZoom(false)} - doubleTapToZoomEnabled={true} - images={[assetSource]} - /> - - <Pressable onPress={() => setImageZoom(true)}> - <BookmarkAssetImage - assetId={bookmark.content.assetId} - className="h-56 min-h-56 w-full object-cover" - /> - </Pressable> - </View> - ); -} - -export default function ListView() { +export default function BookmarkView() { + const insets = useSafeAreaInsets(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -431,7 +71,10 @@ export default function ListView() { break; } return ( - <CustomSafeAreaView edges={["bottom"]}> + <KeyboardAvoidingView + style={{ flex: 1, paddingBottom: insets.bottom + 8 }} + behavior="height" + > <Stack.Screen options={{ headerTitle: title ?? "", @@ -452,10 +95,8 @@ export default function ListView() { ) : undefined, }} /> - <View className="flex h-full"> - {comp} - <BottomActions bookmark={bookmark} /> - </View> - </CustomSafeAreaView> + {comp} + <BottomActions bookmark={bookmark} /> + </KeyboardAvoidingView> ); } diff --git a/apps/mobile/components/bookmarks/BookmarkAssetView.tsx b/apps/mobile/components/bookmarks/BookmarkAssetView.tsx new file mode 100644 index 00000000..5fe2f470 --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkAssetView.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import ImageView from "react-native-image-viewing"; +import BookmarkAssetImage from "@/components/bookmarks/BookmarkAssetImage"; +import { PDFViewer } from "@/components/bookmarks/PDFViewer"; +import { useAssetUrl } from "@/lib/hooks"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +interface BookmarkAssetViewProps { + bookmark: ZBookmark; +} + +export default function BookmarkAssetView({ + bookmark, +}: BookmarkAssetViewProps) { + const [imageZoom, setImageZoom] = useState(false); + + if (bookmark.content.type !== BookmarkTypes.ASSET) { + throw new Error("Wrong content type rendered"); + } + + const assetSource = useAssetUrl(bookmark.content.assetId); + + // Check if this is a PDF asset + if (bookmark.content.assetType === "pdf") { + return ( + <View className="flex flex-1"> + <PDFViewer + source={assetSource.uri ?? ""} + headers={assetSource.headers} + /> + </View> + ); + } + + // Handle image assets as before + return ( + <View className="flex flex-1 gap-2"> + <ImageView + visible={imageZoom} + imageIndex={0} + onRequestClose={() => setImageZoom(false)} + doubleTapToZoomEnabled={true} + images={[assetSource]} + /> + + <Pressable onPress={() => setImageZoom(true)}> + <BookmarkAssetImage + assetId={bookmark.content.assetId} + className="h-56 min-h-56 w-full object-cover" + /> + </Pressable> + </View> + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx new file mode 100644 index 00000000..58cbcc8d --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx @@ -0,0 +1,85 @@ +import * as Haptics from "expo-haptics"; +import { MenuView } from "@react-native-menu/menu"; +import { ChevronDown } from "lucide-react-native"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +export type BookmarkLinkType = "browser" | "reader" | "screenshot" | "archive"; + +function getAvailableViewTypes(bookmark: ZBookmark): BookmarkLinkType[] { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return []; + } + + const availableTypes: BookmarkLinkType[] = ["browser", "reader"]; + + if (bookmark.assets.some((asset) => asset.assetType === "screenshot")) { + availableTypes.push("screenshot"); + } + + if ( + bookmark.assets.some( + (asset) => + asset.assetType === "precrawledArchive" || + asset.assetType === "fullPageArchive", + ) + ) { + availableTypes.push("archive"); + } + + return availableTypes; +} + +interface BookmarkLinkTypeSelectorProps { + type: BookmarkLinkType; + onChange: (type: BookmarkLinkType) => void; + bookmark: ZBookmark; +} + +export default function BookmarkLinkTypeSelector({ + type, + onChange, + bookmark, +}: BookmarkLinkTypeSelectorProps) { + const availableTypes = getAvailableViewTypes(bookmark); + + const allActions = [ + { + id: "reader" as const, + title: "Reader View", + state: type === "reader" ? ("on" as const) : undefined, + }, + { + id: "browser" as const, + title: "Browser", + state: type === "browser" ? ("on" as const) : undefined, + }, + { + id: "screenshot" as const, + title: "Screenshot", + state: type === "screenshot" ? ("on" as const) : undefined, + }, + { + id: "archive" as const, + title: "Archived Page", + state: type === "archive" ? ("on" as const) : undefined, + }, + ]; + + const availableActions = allActions.filter((action) => + availableTypes.includes(action.id), + ); + + return ( + <MenuView + onPressAction={({ nativeEvent }) => { + Haptics.selectionAsync(); + onChange(nativeEvent.event as BookmarkLinkType); + }} + actions={availableActions} + shouldOpenOnLongPress={false} + > + <ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" /> + </MenuView> + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkLinkView.tsx b/apps/mobile/components/bookmarks/BookmarkLinkView.tsx new file mode 100644 index 00000000..e8a78029 --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkLinkView.tsx @@ -0,0 +1,35 @@ +import { + BookmarkLinkArchivePreview, + BookmarkLinkBrowserPreview, + BookmarkLinkReaderPreview, + BookmarkLinkScreenshotPreview, +} from "@/components/bookmarks/BookmarkLinkPreview"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import { BookmarkLinkType } from "./BookmarkLinkTypeSelector"; + +interface BookmarkLinkViewProps { + bookmark: ZBookmark; + bookmarkPreviewType: BookmarkLinkType; +} + +export default function BookmarkLinkView({ + bookmark, + bookmarkPreviewType, +}: BookmarkLinkViewProps) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + throw new Error("Wrong content type rendered"); + } + + switch (bookmarkPreviewType) { + case "browser": + return <BookmarkLinkBrowserPreview bookmark={bookmark} />; + case "reader": + return <BookmarkLinkReaderPreview bookmark={bookmark} />; + case "screenshot": + return <BookmarkLinkScreenshotPreview bookmark={bookmark} />; + case "archive": + return <BookmarkLinkArchivePreview bookmark={bookmark} />; + } +} diff --git a/apps/mobile/components/bookmarks/BookmarkTextView.tsx b/apps/mobile/components/bookmarks/BookmarkTextView.tsx new file mode 100644 index 00000000..0f7a7291 --- /dev/null +++ b/apps/mobile/components/bookmarks/BookmarkTextView.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { Keyboard, Pressable, ScrollView, TextInput, View } from "react-native"; +import BookmarkTextMarkdown from "@/components/bookmarks/BookmarkTextMarkdown"; +import { Button } from "@/components/ui/Button"; +import { Text } from "@/components/ui/Text"; +import { useToast } from "@/components/ui/Toast"; +import { useColorScheme } from "nativewind"; + +import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +interface BookmarkTextViewProps { + bookmark: ZBookmark; +} + +export default function BookmarkTextView({ bookmark }: BookmarkTextViewProps) { + if (bookmark.content.type !== BookmarkTypes.TEXT) { + throw new Error("Wrong content type rendered"); + } + const { toast } = useToast(); + const { colorScheme } = useColorScheme(); + + const [isEditing, setIsEditing] = useState(false); + const initialText = bookmark.content.text; + const [content, setContent] = useState(initialText); + + const { mutate, isPending } = useUpdateBookmark({ + onError: () => { + toast({ + message: "Something went wrong", + variant: "destructive", + }); + }, + onSuccess: () => { + setIsEditing(false); + toast({ + message: "Text updated successfully", + showProgress: false, + }); + }, + }); + + const handleSave = () => { + mutate({ + bookmarkId: bookmark.id, + text: content, + }); + }; + + const handleDiscard = () => { + setContent(initialText); + setIsEditing(false); + Keyboard.dismiss(); + }; + + if (isEditing) { + return ( + <View className="flex-1 p-4"> + <View className="flex-row justify-end gap-2 px-4 py-2"> + <Button + size="sm" + onPress={handleDiscard} + disabled={isPending} + variant="plain" + > + <Text>Cancel</Text> + </Button> + <Button size="sm" onPress={handleSave} disabled={isPending}> + <Text>{isPending ? "Saving..." : "Save"}</Text> + </Button> + </View> + + <TextInput + value={content} + onChangeText={setContent} + multiline + autoFocus + editable={!isPending} + placeholder="Enter your text here..." + placeholderTextColor={colorScheme === "dark" ? "#666" : "#999"} + style={{ + flex: 1, + fontSize: 16, + lineHeight: 24, + color: colorScheme === "dark" ? "#fff" : "#000", + textAlignVertical: "top", + padding: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: colorScheme === "dark" ? "#333" : "#ddd", + backgroundColor: colorScheme === "dark" ? "#111" : "#fff", + }} + /> + </View> + ); + } + + return ( + <ScrollView className="m-4 flex-1 rounded-lg border border-border bg-card p-2"> + <Pressable onPress={() => setIsEditing(true)}> + <View className="min-h-[200px] rounded-xl p-4"> + <BookmarkTextMarkdown text={content} /> + {content.trim() === "" && ( + <Text className="italic text-muted-foreground"> + Tap to add text... + </Text> + )} + </View> + </Pressable> + </ScrollView> + ); +} diff --git a/apps/mobile/components/bookmarks/BottomActions.tsx b/apps/mobile/components/bookmarks/BottomActions.tsx new file mode 100644 index 00000000..8cfa27c9 --- /dev/null +++ b/apps/mobile/components/bookmarks/BottomActions.tsx @@ -0,0 +1,136 @@ +import { Alert, Linking, Pressable, View } from "react-native"; +import { useRouter } from "expo-router"; +import { TailwindResolver } from "@/components/TailwindResolver"; +import { useToast } from "@/components/ui/Toast"; +import { ClipboardList, Globe, Info, Tag, Trash2 } from "lucide-react-native"; + +import { useDeleteBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +interface BottomActionsProps { + bookmark: ZBookmark; +} + +export default function BottomActions({ bookmark }: BottomActionsProps) { + const { toast } = useToast(); + const router = useRouter(); + + const { mutate: deleteBookmark, isPending: isDeletionPending } = + useDeleteBookmark({ + onSuccess: () => { + router.back(); + toast({ + message: "The bookmark has been deleted!", + showProgress: false, + }); + }, + onError: () => { + toast({ + message: "Something went wrong", + variant: "destructive", + showProgress: false, + }); + }, + }); + + const deleteBookmarkAlert = () => + Alert.alert( + "Delete bookmark?", + "Are you sure you want to delete this bookmark?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + onPress: () => deleteBookmark({ bookmarkId: bookmark.id }), + style: "destructive", + }, + ], + ); + + const actions = [ + { + id: "lists", + icon: ( + <TailwindResolver + className="text-foreground" + comp={(styles) => <ClipboardList color={styles?.color?.toString()} />} + /> + ), + shouldRender: true, + onClick: () => + router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`), + disabled: false, + }, + { + id: "tags", + icon: ( + <TailwindResolver + className="text-foreground" + comp={(styles) => <Tag color={styles?.color?.toString()} />} + /> + ), + shouldRender: true, + onClick: () => + router.push(`/dashboard/bookmarks/${bookmark.id}/manage_tags`), + disabled: false, + }, + { + id: "open", + icon: ( + <TailwindResolver + className="text-foreground" + comp={(styles) => <Info color={styles?.color?.toString()} />} + /> + ), + shouldRender: true, + onClick: () => router.push(`/dashboard/bookmarks/${bookmark.id}/info`), + disabled: false, + }, + { + id: "delete", + icon: ( + <TailwindResolver + className="text-foreground" + comp={(styles) => <Trash2 color={styles?.color?.toString()} />} + /> + ), + shouldRender: true, + onClick: deleteBookmarkAlert, + disabled: isDeletionPending, + }, + { + id: "browser", + icon: ( + <TailwindResolver + className="text-foreground" + comp={(styles) => <Globe color={styles?.color?.toString()} />} + /> + ), + shouldRender: bookmark.content.type == BookmarkTypes.LINK, + onClick: () => + bookmark.content.type == BookmarkTypes.LINK && + Linking.openURL(bookmark.content.url), + disabled: false, + }, + ]; + + return ( + <View> + <View className="flex flex-row items-center justify-between px-10 pb-2 pt-4"> + {actions.map( + (a) => + a.shouldRender && ( + <Pressable + disabled={a.disabled} + key={a.id} + onPress={a.onClick} + className="py-auto" + > + {a.icon} + </Pressable> + ), + )} + </View> + </View> + ); +} |
