diff options
Diffstat (limited to 'apps/mobile/app')
| -rw-r--r-- | apps/mobile/app/+not-found.tsx | 6 | ||||
| -rw-r--r-- | apps/mobile/app/_layout.tsx | 53 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/_layout.tsx | 38 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/index.tsx | 31 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/lists.tsx | 67 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/search.tsx | 35 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/settings.tsx | 41 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/_layout.tsx | 38 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/add-link.tsx | 57 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/add-note.tsx | 53 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/archive.tsx | 11 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/favourites.tsx | 11 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/lists/[slug].tsx | 31 | ||||
| -rw-r--r-- | apps/mobile/app/error.tsx | 9 | ||||
| -rw-r--r-- | apps/mobile/app/index.tsx | 20 | ||||
| -rw-r--r-- | apps/mobile/app/sharing.tsx | 99 | ||||
| -rw-r--r-- | apps/mobile/app/signin.tsx | 101 |
17 files changed, 701 insertions, 0 deletions
diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx new file mode 100644 index 00000000..466505b6 --- /dev/null +++ b/apps/mobile/app/+not-found.tsx @@ -0,0 +1,6 @@ +import { View } from "react-native"; + +// This is kinda important given that the sharing modal always resolve to an unknown route +export default function NotFound() { + return <View />; +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 00000000..6304ced5 --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,53 @@ +import "@/globals.css"; +import "expo-dev-client"; + +import { useRouter } from "expo-router"; +import { Stack } from "expo-router/stack"; +import { useShareIntent } from "expo-share-intent"; +import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { View } from "react-native"; + +import { useLastSharedIntent } from "@/lib/last-shared-intent"; +import { Providers } from "@/lib/providers"; + +export default function RootLayout() { + const router = useRouter(); + const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent(); + + const lastSharedIntent = useLastSharedIntent(); + + useEffect(() => { + const intentJson = JSON.stringify(shareIntent); + if (hasShareIntent && !lastSharedIntent.isPreviouslyShared(intentJson)) { + // TODO: Remove once https://github.com/achorein/expo-share-intent/issues/14 is fixed + lastSharedIntent.setIntent(intentJson); + router.replace({ + pathname: "sharing", + params: { shareIntent: intentJson }, + }); + resetShareIntent(); + } + }, [hasShareIntent]); + + return ( + <Providers> + <View className="h-full w-full bg-white"> + <Stack + screenOptions={{ + headerShown: false, + }} + > + <Stack.Screen name="index" /> + <Stack.Screen + name="sharing" + options={{ + presentation: "modal", + }} + /> + </Stack> + <StatusBar style="auto" /> + </View> + </Providers> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx new file mode 100644 index 00000000..5b2d810a --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx @@ -0,0 +1,38 @@ +import { Tabs } from "expo-router"; +import { ClipboardList, Home, Search, Settings } from "lucide-react-native"; +import React from "react"; + +export default function TabLayout() { + return ( + <Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}> + <Tabs.Screen + name="index" + options={{ + title: "Home", + tabBarIcon: ({ color }) => <Home color={color} />, + }} + /> + <Tabs.Screen + name="search" + options={{ + title: "Search", + tabBarIcon: ({ color }) => <Search color={color} />, + }} + /> + <Tabs.Screen + name="lists" + options={{ + title: "Lists", + tabBarIcon: ({ color }) => <ClipboardList color={color} />, + }} + /> + <Tabs.Screen + name="settings" + options={{ + title: "Settings", + tabBarIcon: ({ color }) => <Settings color={color} />, + }} + /> + </Tabs> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx new file mode 100644 index 00000000..b2349525 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/index.tsx @@ -0,0 +1,31 @@ +import { Link, Stack } from "expo-router"; +import { SquarePen, Link as LinkIcon } from "lucide-react-native"; +import { View } from "react-native"; + +import BookmarkList from "@/components/bookmarks/BookmarkList"; + +function HeaderRight() { + return ( + <View className="flex flex-row"> + <Link href="dashboard/add-link" className="mt-2 px-2"> + <LinkIcon /> + </Link> + <Link href="dashboard/add-note" className="mt-2 px-2"> + <SquarePen /> + </Link> + </View> + ); +} + +export default function Home() { + return ( + <> + <Stack.Screen + options={{ + headerRight: () => <HeaderRight />, + }} + /> + <BookmarkList archived={false} /> + </> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx new file mode 100644 index 00000000..b534ddda --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/lists.tsx @@ -0,0 +1,67 @@ +import { Link } from "expo-router"; +import { useEffect, useState } from "react"; +import { FlatList, View } from "react-native"; + +import { api } from "@/lib/trpc"; + +export default function Lists() { + const [refreshing, setRefreshing] = useState(false); + const { data: lists, isPending } = api.lists.list.useQuery(); + const apiUtils = api.useUtils(); + + useEffect(() => { + setRefreshing(isPending); + }, [isPending]); + + if (!lists) { + // Add spinner + return <View />; + } + + const onRefresh = () => { + apiUtils.lists.list.invalidate(); + }; + + const links = [ + { + id: "fav", + logo: "⭐️", + name: "Favourites", + href: "/dashboard/favourites", + }, + { + id: "arch", + logo: "🗄️", + name: "Archive", + href: "/dashboard/archive", + }, + ]; + + links.push( + ...lists.lists.map((l) => ({ + id: l.id, + logo: l.icon, + name: l.name, + href: `/dashboard/lists/${l.id}`, + })), + ); + + return ( + <FlatList + contentContainerStyle={{ + gap: 10, + marginTop: 10, + }} + renderItem={(l) => ( + <View className="mx-2 block rounded-xl border border-gray-100 bg-white px-4 py-2"> + <Link key={l.item.id} href={l.item.href} className="text-lg"> + {l.item.logo} {l.item.name} + </Link> + </View> + )} + data={links} + refreshing={refreshing} + onRefresh={onRefresh} + /> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/search.tsx b/apps/mobile/app/dashboard/(tabs)/search.tsx new file mode 100644 index 00000000..980cab36 --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/search.tsx @@ -0,0 +1,35 @@ +import { keepPreviousData } from "@tanstack/react-query"; +import { useState } from "react"; +import { View } from "react-native"; +import { useDebounce } from "use-debounce"; + +import BookmarkList from "@/components/bookmarks/BookmarkList"; +import { Divider } from "@/components/ui/Divider"; +import { Input } from "@/components/ui/Input"; +import { api } from "@/lib/trpc"; + +export default function Search() { + const [search, setSearch] = useState(""); + + const [query] = useDebounce(search, 200); + + const { data } = api.bookmarks.searchBookmarks.useQuery( + { text: query }, + { placeholderData: keepPreviousData }, + ); + + return ( + <View> + <Input + placeholder="Search" + className="mx-4 mt-4 bg-white" + value={search} + onChangeText={setSearch} + autoFocus + autoCapitalize="none" + /> + <Divider orientation="horizontal" className="mb-1 mt-4 w-full" /> + {data && <BookmarkList ids={data.bookmarks.map((b) => b.id)} />} + </View> + ); +} diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx new file mode 100644 index 00000000..9f86d5ec --- /dev/null +++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx @@ -0,0 +1,41 @@ +import { useRouter } from "expo-router"; +import { useEffect } from "react"; +import { Text, View } from "react-native"; + +import Logo from "@/components/Logo"; +import { Button } from "@/components/ui/Button"; +import { useSession } from "@/lib/session"; +import { api } from "@/lib/trpc"; + +export default function Dashboard() { + const router = useRouter(); + + const { isLoggedIn, logout } = useSession(); + + useEffect(() => { + if (isLoggedIn !== undefined && !isLoggedIn) { + router.replace("signin"); + } + }, [isLoggedIn]); + + const { data, error, isLoading } = api.users.whoami.useQuery(); + + useEffect(() => { + if (error?.data?.code === "UNAUTHORIZED") { + logout(); + } + }, [error]); + + return ( + <View className="flex h-full w-full items-center gap-4 p-4"> + <Logo /> + <View className="w-full rounded-lg bg-white px-4 py-2"> + <Text className="text-lg"> + {isLoading ? "Loading ..." : data?.email} + </Text> + </View> + + <Button className="w-full" label="Log Out" onPress={logout} /> + </View> + ); +} diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx new file mode 100644 index 00000000..ff2384d2 --- /dev/null +++ b/apps/mobile/app/dashboard/_layout.tsx @@ -0,0 +1,38 @@ +import { Stack } from "expo-router/stack"; + +export default function Dashboard() { + return ( + <Stack> + <Stack.Screen + name="(tabs)" + options={{ headerShown: false, title: "Home" }} + /> + <Stack.Screen + name="favourites" + options={{ + title: "⭐️ Favourites", + }} + /> + <Stack.Screen + name="archive" + options={{ + title: "🗄️ Archive", + }} + /> + <Stack.Screen + name="add-link" + options={{ + title: "New link", + presentation: "modal", + }} + /> + <Stack.Screen + name="add-note" + options={{ + title: "New Note", + presentation: "modal", + }} + /> + </Stack> + ); +} diff --git a/apps/mobile/app/dashboard/add-link.tsx b/apps/mobile/app/dashboard/add-link.tsx new file mode 100644 index 00000000..69a9c7a2 --- /dev/null +++ b/apps/mobile/app/dashboard/add-link.tsx @@ -0,0 +1,57 @@ +import { useRouter } from "expo-router"; +import { useState } from "react"; +import { View, Text } from "react-native"; + +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { api } from "@/lib/trpc"; + +export default function AddNote() { + const [text, setText] = useState(""); + const [error, setError] = useState<string | undefined>(); + const router = useRouter(); + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate } = api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateAllBookmarks(); + if (router.canGoBack()) { + router.replace("../"); + } else { + router.replace("dashboard"); + } + }, + onError: (e) => { + let message; + if (e.data?.code === "BAD_REQUEST") { + const error = JSON.parse(e.message)[0]; + message = error.message; + } else { + message = `Something went wrong: ${e.message}`; + } + setError(message); + }, + }); + + return ( + <View className="flex gap-2 p-4"> + {error && ( + <Text className="w-full text-center text-red-500">{error}</Text> + )} + <Input + className="bg-white" + value={text} + onChangeText={setText} + placeholder="Link" + autoCapitalize="none" + inputMode="url" + autoFocus + /> + <Button + onPress={() => mutate({ type: "link", url: text })} + label="Add Link" + /> + </View> + ); +} diff --git a/apps/mobile/app/dashboard/add-note.tsx b/apps/mobile/app/dashboard/add-note.tsx new file mode 100644 index 00000000..cf775a15 --- /dev/null +++ b/apps/mobile/app/dashboard/add-note.tsx @@ -0,0 +1,53 @@ +import { useRouter } from "expo-router"; +import { useState } from "react"; +import { View, Text } from "react-native"; + +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { api } from "@/lib/trpc"; + +export default function AddNote() { + const [text, setText] = useState(""); + const [error, setError] = useState<string | undefined>(); + const router = useRouter(); + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate } = api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateAllBookmarks(); + if (router.canGoBack()) { + router.replace("../"); + } else { + router.replace("dashboard"); + } + }, + onError: (e) => { + let message; + if (e.data?.code === "BAD_REQUEST") { + const error = JSON.parse(e.message)[0]; + message = error.message; + } else { + message = `Something went wrong: ${e.message}`; + } + setError(message); + }, + }); + + return ( + <View className="flex gap-2 p-4"> + {error && ( + <Text className="w-full text-center text-red-500">{error}</Text> + )} + <Input + className="bg-white" + value={text} + onChangeText={setText} + multiline + placeholder="What's on your mind?" + autoFocus + /> + <Button onPress={() => mutate({ type: "text", text })} label="Add Note" /> + </View> + ); +} diff --git a/apps/mobile/app/dashboard/archive.tsx b/apps/mobile/app/dashboard/archive.tsx new file mode 100644 index 00000000..d75cfe22 --- /dev/null +++ b/apps/mobile/app/dashboard/archive.tsx @@ -0,0 +1,11 @@ +import { View } from "react-native"; + +import BookmarkList from "@/components/bookmarks/BookmarkList"; + +export default function Archive() { + return ( + <View> + <BookmarkList archived /> + </View> + ); +} diff --git a/apps/mobile/app/dashboard/favourites.tsx b/apps/mobile/app/dashboard/favourites.tsx new file mode 100644 index 00000000..90374f18 --- /dev/null +++ b/apps/mobile/app/dashboard/favourites.tsx @@ -0,0 +1,11 @@ +import { View } from "react-native"; + +import BookmarkList from "@/components/bookmarks/BookmarkList"; + +export default function Favourites() { + return ( + <View> + <BookmarkList archived={false} favourited /> + </View> + ); +} diff --git a/apps/mobile/app/dashboard/lists/[slug].tsx b/apps/mobile/app/dashboard/lists/[slug].tsx new file mode 100644 index 00000000..54744874 --- /dev/null +++ b/apps/mobile/app/dashboard/lists/[slug].tsx @@ -0,0 +1,31 @@ +import { useLocalSearchParams, Stack } from "expo-router"; +import { View } from "react-native"; + +import BookmarkList from "@/components/bookmarks/BookmarkList"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { api } from "@/lib/trpc"; + +export default function ListView() { + const { slug } = useLocalSearchParams(); + if (typeof slug !== "string") { + throw new Error("Unexpected param type"); + } + const { data: list } = api.lists.get.useQuery({ listId: slug }); + + if (!list) { + return <FullPageSpinner />; + } + + return ( + <> + <Stack.Screen + options={{ + headerTitle: `${list.icon} ${list.name}`, + }} + /> + <View> + <BookmarkList archived={false} ids={list.bookmarks} /> + </View> + </> + ); +} diff --git a/apps/mobile/app/error.tsx b/apps/mobile/app/error.tsx new file mode 100644 index 00000000..2ca227a4 --- /dev/null +++ b/apps/mobile/app/error.tsx @@ -0,0 +1,9 @@ +import { View, Text } from "react-native"; + +export default function ErrorPage() { + return ( + <View className="flex-1 items-center justify-center gap-4"> + <Text className="text-4xl">Error!</Text> + </View> + ); +} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 00000000..5ce20cda --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,20 @@ +import { useRouter } from "expo-router"; +import { useEffect } from "react"; +import { View } from "react-native"; + +import { useSession } from "@/lib/session"; + +export default function App() { + const router = useRouter(); + const { isLoggedIn } = useSession(); + useEffect(() => { + if (isLoggedIn === undefined) { + // Wait until it's loaded + } else if (isLoggedIn) { + router.replace("dashboard"); + } else { + router.replace("signin"); + } + }, [isLoggedIn]); + return <View />; +} diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx new file mode 100644 index 00000000..64bbd933 --- /dev/null +++ b/apps/mobile/app/sharing.tsx @@ -0,0 +1,99 @@ +import { Link, useLocalSearchParams, useRouter } from "expo-router"; +import { ShareIntent, useShareIntent } from "expo-share-intent"; +import { useEffect, useMemo, useState } from "react"; +import { View, Text } from "react-native"; +import { z } from "zod"; + +import { api } from "@/lib/trpc"; + +type Mode = + | { type: "idle" } + | { type: "success"; bookmarkId: string } + | { type: "error" }; + +function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { + // Desperate attempt to fix sharing duplication + const { hasShareIntent, resetShareIntent } = useShareIntent(); + + const params = useLocalSearchParams(); + + const shareIntent = useMemo(() => { + if (params && params.shareIntent) { + if (typeof params.shareIntent === "string") { + return JSON.parse(params.shareIntent) as ShareIntent; + } + } + return null; + }, [params]); + + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + useEffect(() => { + if (!isPending && shareIntent?.text) { + const val = z.string().url(); + if (val.safeParse(shareIntent.text).success) { + // This is a URL, else treated as text + mutate({ type: "link", url: shareIntent.text }); + } else { + mutate({ type: "text", text: shareIntent.text }); + } + } + if (hasShareIntent) { + resetShareIntent(); + } + }, []); + + const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({ + onSuccess: (d) => { + invalidateAllBookmarks(); + setMode({ type: "success", bookmarkId: d.id }); + }, + onError: () => { + setMode({ type: "error" }); + }, + }); + + return <Text className="text-4xl">Hoarding ...</Text>; +} + +export default function Sharing() { + const router = useRouter(); + const [mode, setMode] = useState<Mode>({ type: "idle" }); + + let comp; + switch (mode.type) { + case "idle": { + comp = <SaveBookmark setMode={setMode} />; + break; + } + case "success": { + comp = <Text className="text-4xl">Hoarded!</Text>; + break; + } + case "error": { + comp = <Text className="text-4xl">Error!</Text>; + break; + } + } + + // Auto dismiss the modal after saving. + useEffect(() => { + if (mode.type === "idle") { + return; + } + + const timeoutId = setTimeout(() => { + router.replace("dashboard"); + }, 2000); + + return () => clearTimeout(timeoutId); + }, [mode.type]); + + return ( + <View className="flex-1 items-center justify-center gap-4"> + {comp} + <Link href="dashboard">Dismiss</Link> + </View> + ); +} diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx new file mode 100644 index 00000000..a89b0087 --- /dev/null +++ b/apps/mobile/app/signin.tsx @@ -0,0 +1,101 @@ +import { useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { View, Text } from "react-native"; + +import Logo from "@/components/Logo"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import useAppSettings from "@/lib/settings"; +import { api } from "@/lib/trpc"; + +export default function Signin() { + const router = useRouter(); + + const { settings, setSettings } = useAppSettings(); + + const [error, setError] = useState<string | undefined>(); + + const { mutate: login, isPending } = api.apiKeys.exchange.useMutation({ + onSuccess: (resp) => { + setSettings({ ...settings, apiKey: resp.key }); + router.replace("dashboard"); + }, + onError: (e) => { + if (e.data?.code === "UNAUTHORIZED") { + setError("Wrong username or password"); + } else { + setError(`${e.message}`); + } + }, + }); + + const [formData, setFormData] = useState<{ + email: string; + password: string; + }>({ + email: "", + password: "", + }); + + useEffect(() => { + if (settings.apiKey) { + router.navigate("dashboard"); + } + }, [settings]); + + const onSignin = () => { + const randStr = (Math.random() + 1).toString(36).substring(5); + login({ ...formData, keyName: `Mobile App: (${randStr})` }); + }; + + return ( + <View className="flex h-full flex-col justify-center gap-2 px-4"> + <View className="items-center"> + <Logo /> + </View> + {error && ( + <Text className="w-full text-center text-red-500">{error}</Text> + )} + <View className="gap-2"> + <Text className="font-bold">Server Address</Text> + <Input + className="w-full" + placeholder="Server Address" + value={settings.address} + autoCapitalize="none" + keyboardType="url" + onEndEditing={(e) => + setSettings({ ...settings, address: e.nativeEvent.text }) + } + /> + </View> + <View className="gap-2"> + <Text className="font-bold">Email</Text> + <Input + className="w-full" + placeholder="Email" + keyboardType="email-address" + autoCapitalize="none" + value={formData.email} + onChangeText={(e) => setFormData((s) => ({ ...s, email: e }))} + /> + </View> + <View className="gap-2"> + <Text className="font-bold">Password</Text> + <Input + className="w-full" + placeholder="Password" + secureTextEntry + value={formData.password} + onChangeText={(e) => setFormData((s) => ({ ...s, password: e }))} + /> + </View> + <Button + className="w-full" + label="Sign In" + onPress={onSignin} + disabled={isPending} + /> + </View> + ); +} |
