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 | |
| 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')
49 files changed, 2009 insertions, 0 deletions
diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js new file mode 100644 index 00000000..53beac49 --- /dev/null +++ b/apps/mobile/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["universe/native"], +}; diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 00000000..2920e5a8 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,39 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +#build files +ios/ +android/ diff --git a/apps/mobile/.npmrc b/apps/mobile/.npmrc new file mode 100644 index 00000000..d67f3748 --- /dev/null +++ b/apps/mobile/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 00000000..e16baa37 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,57 @@ +{ + "expo": { + "name": "Hoarder App", + "slug": "hoarder", + "scheme": "hoarder", + "version": "1.2.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "app.hoarder.hoardermobile", + "config": { + "usesNonExemptEncryption": false + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/icon.png", + "backgroundColor": "#ffffff" + }, + "package": "app.hoarder.hoardermobile" + }, + "plugins": [ + "expo-router", + [ + "expo-share-intent", + { + "iosActivationRules": { + "NSExtensionActivationSupportsWebURLWithMaxCount": 1, + "NSExtensionActivationSupportsWebPageWithMaxCount": 0, + "NSExtensionActivationSupportsImageWithMaxCount": 0, + "NSExtensionActivationSupportsMovieWithMaxCount": 0, + "NSExtensionActivationSupportsText": true + } + } + ], + "expo-secure-store" + ], + "extra": { + "router": { + "origin": false + }, + "eas": { + "projectId": "d6d14643-ad43-4cd3-902a-92c5944d5e45" + } + } + } +} 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> + ); +} diff --git a/apps/mobile/assets/blur.jpeg b/apps/mobile/assets/blur.jpeg Binary files differnew file mode 100644 index 00000000..387ce697 --- /dev/null +++ b/apps/mobile/assets/blur.jpeg diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png Binary files differnew file mode 100644 index 00000000..71ead90c --- /dev/null +++ b/apps/mobile/assets/icon.png diff --git a/apps/mobile/assets/splash.png b/apps/mobile/assets/splash.png Binary files differnew file mode 100644 index 00000000..3759c518 --- /dev/null +++ b/apps/mobile/assets/splash.png diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 00000000..f3c649bb --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,9 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [ + ["babel-preset-expo", { jsxImportSource: "nativewind" }], + "nativewind/babel", + ], + }; +}; 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 }; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 00000000..0897755d --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,19 @@ +{ + "cli": { + "version": ">= 7.5.0", + "promptToConfigurePushNotifications": false + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": {} + }, + "submit": { + "production": {} + } +} diff --git a/apps/mobile/globals.css b/apps/mobile/globals.css new file mode 100644 index 00000000..de1cf559 --- /dev/null +++ b/apps/mobile/globals.css @@ -0,0 +1,80 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 100% 50%; + --destructive-foreground: 210 40% 98%; + + --ring: 215 20.2% 65.1%; + + --radius: 0.5rem; + } + + .dark:root { + --background: 224 71% 4%; + --foreground: 213 31% 91%; + + --muted: 223 47% 11%; + --muted-foreground: 215.4 16.3% 56.9%; + + --accent: 216 34% 17%; + --accent-foreground: 210 40% 98%; + + --popover: 224 71% 4%; + --popover-foreground: 215 20.2% 65.1%; + + --border: 216 34% 17%; + --input: 216 34% 17%; + + --card: 224 71% 4%; + --card-foreground: 213 31% 91%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 222.2 47.4% 11.2%; + --secondary-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --ring: 216 34% 17%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/mobile/lib/last-shared-intent.ts b/apps/mobile/lib/last-shared-intent.ts new file mode 100644 index 00000000..951bcf74 --- /dev/null +++ b/apps/mobile/lib/last-shared-intent.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +interface LastSharedIntent { + lastIntent: string; + setIntent: (intent: string) => void; + isPreviouslyShared: (intent: string) => boolean; +} + +export const useLastSharedIntent = create<LastSharedIntent>((set, get) => ({ + lastIntent: "", + setIntent: (intent: string) => set({ lastIntent: intent }), + isPreviouslyShared: (intent: string) => { + return get().lastIntent === intent; + }, +})); diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx new file mode 100644 index 00000000..1717afb2 --- /dev/null +++ b/apps/mobile/lib/providers.tsx @@ -0,0 +1,54 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { httpBatchLink } from "@trpc/client"; +import { useEffect, useState } from "react"; +import superjson from "superjson"; + +import useAppSettings, { getAppSettings } from "./settings"; +import { api } from "./trpc"; + +import { ToastProvider } from "@/components/ui/Toast"; + +function getTRPCClient(address: string) { + return api.createClient({ + links: [ + httpBatchLink({ + url: `${address}/api/trpc`, + async headers() { + const settings = await getAppSettings(); + return { + Authorization: + settings && settings.apiKey + ? `Bearer ${settings.apiKey}` + : undefined, + }; + }, + transformer: superjson, + }), + ], + }); +} + +export function Providers({ children }: { children: React.ReactNode }) { + const { settings } = useAppSettings(); + const [queryClient] = useState(() => new QueryClient()); + + const [trpcClient, setTrpcClient] = useState< + ReturnType<typeof getTRPCClient> + >(getTRPCClient(settings.address)); + + useEffect(() => { + setTrpcClient(getTRPCClient(settings.address)); + }, [settings.address]); + + return ( + <api.Provider + key={settings.address} + client={trpcClient} + queryClient={queryClient} + > + <QueryClientProvider client={queryClient}> + <ToastProvider>{children}</ToastProvider> + </QueryClientProvider> + </api.Provider> + ); +} diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts new file mode 100644 index 00000000..e2ab245b --- /dev/null +++ b/apps/mobile/lib/session.ts @@ -0,0 +1,20 @@ +import { useCallback, useMemo } from "react"; + +import useAppSettings from "./settings"; + +export function useSession() { + const { settings, isLoading, setSettings } = useAppSettings(); + const isLoggedIn = useMemo(() => { + return isLoading ? undefined : !!settings.apiKey; + }, [isLoading, settings]); + + const logout = useCallback(() => { + setSettings({ ...settings, apiKey: undefined }); + }, [settings]); + + return { + isLoggedIn, + isLoading, + logout, + }; +} diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts new file mode 100644 index 00000000..21f40528 --- /dev/null +++ b/apps/mobile/lib/settings.ts @@ -0,0 +1,29 @@ +import * as SecureStore from "expo-secure-store"; + +import { useStorageState } from "./storage-state"; + +const SETTING_NAME = "settings"; + +export type Settings = { + apiKey?: string; + address: string; +}; + +export default function useAppSettings() { + let [[isLoading, settings], setSettings] = + useStorageState<Settings>(SETTING_NAME); + + settings ||= { + address: "https://demo.hoarder.app", + }; + + return { settings, setSettings, isLoading }; +} + +export async function getAppSettings() { + const val = await SecureStore.getItemAsync(SETTING_NAME); + if (!val) { + return null; + } + return JSON.parse(val) as Settings; +} diff --git a/apps/mobile/lib/storage-state.ts b/apps/mobile/lib/storage-state.ts new file mode 100644 index 00000000..4988f0e0 --- /dev/null +++ b/apps/mobile/lib/storage-state.ts @@ -0,0 +1,51 @@ +import * as SecureStore from "expo-secure-store"; +import * as React from "react"; + +type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void]; + +function useAsyncState<T>( + initialValue: [boolean, T | null] = [true, null], +): UseStateHook<T> { + return React.useReducer( + ( + state: [boolean, T | null], + action: T | null = null, + ): [boolean, T | null] => [false, action], + initialValue, + ) as UseStateHook<T>; +} + +export async function setStorageItemAsync(key: string, value: string | null) { + if (value == null) { + await SecureStore.deleteItemAsync(key); + } else { + await SecureStore.setItemAsync(key, value); + } +} + +export function useStorageState<T>(key: string): UseStateHook<T> { + // Public + const [state, setState] = useAsyncState<T>(); + + // Get + React.useEffect(() => { + SecureStore.getItemAsync(key).then((value) => { + if (!value) { + setState(null); + return null; + } + setState(JSON.parse(value)); + }); + }, [key]); + + // Set + const setValue = React.useCallback( + (value: T | null) => { + setState(value); + setStorageItemAsync(key, JSON.stringify(value)); + }, + [key], + ); + + return [state, setValue]; +} diff --git a/apps/mobile/lib/trpc.ts b/apps/mobile/lib/trpc.ts new file mode 100644 index 00000000..6b428bd9 --- /dev/null +++ b/apps/mobile/lib/trpc.ts @@ -0,0 +1,4 @@ +import type { AppRouter } from "@hoarder/trpc/routers/_app"; +import { createTRPCReact } from "@trpc/react-query"; + +export const api = createTRPCReact<AppRouter>(); diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts new file mode 100644 index 00000000..365058ce --- /dev/null +++ b/apps/mobile/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 00000000..c5630a83 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,58 @@ +// Learn more: https://docs.expo.dev/guides/monorepos/ +const { getDefaultConfig } = require("expo/metro-config"); +const { FileStore } = require("metro-cache"); +const { withNativeWind } = require("nativewind/metro"); + +const path = require("path"); + +module.exports = withTurborepoManagedCache( + withMonorepoPaths( + withNativeWind(getDefaultConfig(__dirname), { + input: "./globals.css", + configPath: "./tailwind.config.ts", + }), + ), +); + +/** + * Add the monorepo paths to the Metro config. + * This allows Metro to resolve modules from the monorepo. + * + * @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config + * @param {import('expo/metro-config').MetroConfig} config + * @returns {import('expo/metro-config').MetroConfig} + */ +function withMonorepoPaths(config) { + const projectRoot = __dirname; + const workspaceRoot = path.resolve(projectRoot, "../.."); + + // #1 - Watch all files in the monorepo + config.watchFolders = [workspaceRoot]; + + // #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules + config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), + ]; + + return config; +} + +/** + * Move the Metro cache to the `node_modules/.cache/metro` folder. + * This repository configured Turborepo to use this cache location as well. + * If you have any environment variables, you can configure Turborepo to invalidate it when needed. + * + * @see https://turbo.build/repo/docs/reference/configuration#env + * @param {import('expo/metro-config').MetroConfig} config + * @returns {import('expo/metro-config').MetroConfig} + */ +function withTurborepoManagedCache(config) { + config.cacheStores = [ + new FileStore({ root: path.join(__dirname, "node_modules/.cache/metro") }), + ]; + return config; +} + + + diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts new file mode 100644 index 00000000..a13e3136 --- /dev/null +++ b/apps/mobile/nativewind-env.d.ts @@ -0,0 +1 @@ +/// <reference types="nativewind/types" /> diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 00000000..80036724 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,71 @@ +{ + "name": "@hoarder/mobile", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "clean": "git clean -xdf .expo .turbo node_modules", + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web", + "lint": "eslint .", + "format": "prettier --check . --ignore-path .gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hoarder/trpc": "0.1.0", + "@tanstack/react-query": "^5.24.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "expo": "~50.0.11", + "expo-config-plugin-ios-share-extension": "^0.0.4", + "expo-constants": "~15.4.5", + "expo-dev-client": "^3.3.9", + "expo-image": "^1.10.6", + "expo-linking": "~6.2.2", + "expo-router": "~3.4.8", + "expo-secure-store": "^12.8.1", + "expo-share-intent": "^1.0.1", + "expo-status-bar": "~1.11.1", + "expo-web-browser": "^12.8.2", + "lucide-react-native": "^0.354.0", + "nativewind": "^4.0.1", + "react": "^18.2.0", + "react-native": "0.73.4", + "react-native-markdown-display": "^7.0.2", + "react-native-reanimated": "^3.8.0", + "react-native-safe-area-context": "4.8.2", + "react-native-screens": "~3.29.0", + "react-native-svg": "^15.1.0", + "tailwind-merge": "^2.2.1", + "use-debounce": "^10.0.0", + "zod": "^3.22.4", + "zustand": "^4.5.1" + }, + "devDependencies": { + "@hoarder/eslint-config": "workspace:^0.2.0", + "@hoarder/prettier-config": "workspace:^0.1.0", + "@hoarder/tailwind-config": "workspace:^0.1.0", + "@hoarder/tsconfig": "workspace:^0.1.0", + "@babel/core": "^7.20.0", + "@types/react": "^18.2.55", + "ajv": "latest", + "eslint": "^8.57.0", + "eslint-config-universe": "^12.0.0", + "prettier": "^3.2.5", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + }, + "private": true, + "eslintConfig": { + "root": true, + "extends": [ + "@hoarder/eslint-config/base", + "@hoarder/eslint-config/react" + ], + "ignorePatterns": [ + "expo-plugins/**" + ] + }, + "prettier": "@hoarder/prettier-config" +} diff --git a/apps/mobile/tailwind.config.ts b/apps/mobile/tailwind.config.ts new file mode 100644 index 00000000..ce0059d4 --- /dev/null +++ b/apps/mobile/tailwind.config.ts @@ -0,0 +1,73 @@ +import type { Config } from "tailwindcss"; +import { hairlineWidth } from "nativewind/theme"; + +const config = { + content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"], + plugins: [], + presets: [require("nativewind/preset")], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderWidth: { + hairline: hairlineWidth(), + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, +} satisfies Config; + +export default config; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 00000000..3bcf5741 --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "types": ["nativewind/types"], + "incremental": true, + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "exclude": ["node_modules"] +} |
