├── .gitignore
├── .npmrc
├── README.md
├── app.db
├── app.db-journal
├── app.json
├── app
├── (tabs)
│ ├── _layout.tsx
│ ├── explore.tsx
│ ├── index.tsx
│ └── new-notion.tsx
├── +html.tsx
├── +not-found.tsx
├── _layout.tsx
├── doc-actions-sheet.tsx
└── new-notion.tsx
├── assets
├── fonts
│ └── SpaceMono-Regular.ttf
└── images
│ ├── adaptive-icon.png
│ ├── favicon.png
│ ├── icon.png
│ ├── partial-react-logo.png
│ ├── react-logo.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── splash.png
├── babel.config.js
├── components
├── Collapsible.tsx
├── DraggableNotionList.tsx
├── DraggableNotionListItem.tsx
├── ExternalLink.tsx
├── HelloWave.tsx
├── NotionButton.tsx
├── ParallaxScrollView.tsx
├── ResentFileCard.tsx
├── ResentFiles.tsx
├── ThemedText.tsx
├── ThemedView.tsx
├── __tests__
│ ├── ThemedText-test.tsx
│ └── __snapshots__
│ │ └── ThemedText-test.tsx.snap
└── navigation
│ └── TabBarIcon.tsx
├── constants
├── Colors.ts
├── Gradients.ts
└── MarkdownStyle.ts
├── eas.json
├── hooks
├── useColorScheme.ts
├── useColorScheme.web.ts
└── useThemeColor.ts
├── migrations
├── 20240703032200_one
│ └── migration.sql
├── 20240703215900_add_order_to_notionfile
│ └── migration.sql
├── 20240704014730_add_on_delete_cascade_for_notion_files
│ └── migration.sql
└── migration_lock.toml
├── myDbModule.ts
├── package.json
├── pnpm-lock.yaml
├── queries.sql
├── schema.prisma
├── scripts
└── reset-project.js
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 | ios/
13 | android/
14 |
15 | # macOS
16 | .DS_Store
17 |
18 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
19 | # The following patterns were generated by expo-cli
20 |
21 | expo-env.d.ts
22 | # @end expo-cli
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | node-linker=hoisted
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | # Welcome to Local-First Notion Clone
3 |
4 | This project is built using Prisma & Expo.
5 |
6 | **Live demo at:** [x.com/betomoedano/status/1812483695418261620](https://x.com/betomoedano/status/1812483695418261620)
7 |
8 | **YouTube, Source Code, Dependencies and More at:** [codewithbeto.dev/projects/notion-clone](https://codewithbeto.dev/projects/notion-clone)
9 |
10 | ## Get started
11 |
12 | 1. Install dependencies with pnpm
13 |
14 | ```bash
15 | pnpm install
16 | ```
17 |
18 | 2. prebuild app
19 |
20 | Haven't tested on Android yet!
21 |
22 | ```bash
23 | npx expo prebuild -p ios --clean && pnpm ios
24 | ```
25 |
26 | You can create the database file and initial migration using Prisma migrate:
27 |
28 | ```bash
29 | npx prisma@latest migrate dev
30 | ```
31 |
32 | you can now generate the Prisma Client like this:
33 |
34 | ```bash
35 | npx prisma@latest generate
36 | ```
37 |
38 | ## Support My Work
39 |
40 | If you find this project helpful and want to support my work, the best way is by enrolling in one of my courses:
41 |
42 | - **React Native Course**: [codewithbeto.dev/learn](https://codewithbeto.dev/learn)
43 | - **React with TypeScript Course**: [codewithbeto.dev/learnReact](https://codewithbeto.dev/learnReact)
44 | - **Git & GitHub Course**: [codewithbeto.dev/learnGit](https://codewithbeto.dev/learnGit)
45 |
46 | For other ways to support my work, please consider:
47 |
48 | - **Become a Code with Beto channel member**: [YouTube Membership](https://www.youtube.com/channel/UCh247h68vszOMA_OWpGEa5g/join)
49 | - **GitHub Sponsors**: [Sponsor Me](https://github.com/sponsors/betomoedano)
50 |
51 | You can also support me by using my referral links:
52 |
53 | - Get an exclusive 40% discount on CodeCrafters: [Referral Link](https://app.codecrafters.io/join?via=betomoedano)
54 | - Get a 10% discount on Vexo Analytics with code "BETO10": [Vexo](https://vexo.co)
55 | - Sign up for Robinhood and we'll both pick our own gift stock 🎁: [Robinhood](https://join.robinhood.com/albertm-8254f5)
56 | - Get 500 MB of Dropbox storage: [Dropbox](https://www.dropbox.com/referrals/AAC52bYrrPqp8FZ7K5gxa-I74wecLpiQuB4?src=global9)
57 |
58 | Your support helps me keep creating amazing projects!
59 |
60 |
61 | ## Connect with Me
62 |
63 | - **Website**: [Code With Beto](https://codewithbeto.dev)
64 | - **X (formerly Twitter)**: [@betomoedano](https://x.com/betomoedano)
65 | - **GitHub**: [betomoedano](https://github.com/betomoedano)
66 | - **LinkedIn**: [Beto Moedano](https://www.linkedin.com/in/betomoedano/)
67 | - **Discord**: [Join Our Community](https://discord.com/invite/G2RnuUD8)
68 | - **Medium**: [@betomoedano01](https://medium.com/@betomoedano01)
69 | - **Figma**: [betomoedano](https://www.figma.com/@betomoedano)
70 |
71 |
--------------------------------------------------------------------------------
/app.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/app.db
--------------------------------------------------------------------------------
/app.db-journal:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/app.db-journal
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "notion-clone",
4 | "slug": "notion-clone",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "splash": {
11 | "image": "./assets/images/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "com.betomoedano.notion-clone"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/images/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "com.betomoedano.notionclone"
25 | },
26 | "web": {
27 | "bundler": "metro",
28 | "output": "static",
29 | "favicon": "./assets/images/favicon.png"
30 | },
31 | "plugins": [
32 | "@prisma/react-native",
33 | "expo-router"
34 | ],
35 | "experiments": {
36 | "typedRoutes": true
37 | },
38 | "extra": {
39 | "router": {
40 | "origin": false
41 | },
42 | "eas": {
43 | "projectId": "f71d029f-67c9-4aa1-aae4-15473a79f760"
44 | }
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "expo-router";
2 | import React from "react";
3 |
4 | import { TabBarIcon } from "@/components/navigation/TabBarIcon";
5 | import { Colors } from "@/constants/Colors";
6 | import { useColorScheme } from "@/hooks/useColorScheme";
7 |
8 | export default function TabLayout() {
9 | const colorScheme = useColorScheme();
10 |
11 | return (
12 |
19 | (
23 |
27 | ),
28 | }}
29 | />
30 | (
34 |
38 | ),
39 | }}
40 | />
41 | (
46 |
50 | ),
51 | headerShown: true,
52 | headerTitle: "",
53 | headerStyle: { shadowColor: "transparent" },
54 | }}
55 | />
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/app/(tabs)/explore.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | StyleSheet,
3 | SafeAreaView,
4 | ScrollView,
5 | TouchableOpacity,
6 | View,
7 | } from "react-native";
8 |
9 | import { ThemedView } from "@/components/ThemedView";
10 | import { extendedClient } from "@/myDbModule";
11 | import { ThemedText } from "@/components/ThemedText";
12 | import { Link } from "expo-router";
13 |
14 | export default function ExploreScreen() {
15 | const today = new Date();
16 | const startOfToday = new Date(today.setHours(0, 0, 0, 0));
17 | const endOfToday = new Date(today.setHours(23, 59, 59, 999));
18 | const startOfLastWeek = new Date(startOfToday);
19 | startOfLastWeek.setDate(startOfToday.getDate() - startOfToday.getDay() - 6); // Start of last week (7 days ago)
20 | const endOfLastWeek = new Date(startOfToday);
21 | endOfLastWeek.setDate(startOfToday.getDate() - startOfToday.getDay()); // End of last week (yesterday)
22 |
23 | const todayFiles = extendedClient.notionFile.useFindMany({
24 | where: {
25 | updatedAt: {
26 | gte: startOfToday,
27 | lte: endOfToday,
28 | },
29 | },
30 | orderBy: {
31 | updatedAt: "desc",
32 | },
33 | });
34 |
35 | const lastWeekFiles = extendedClient.notionFile.useFindMany({
36 | where: {
37 | updatedAt: {
38 | gte: startOfLastWeek,
39 | lte: endOfLastWeek,
40 | },
41 | },
42 | orderBy: {
43 | updatedAt: "desc",
44 | },
45 | });
46 |
47 | return (
48 |
49 |
50 |
54 |
55 | Today
56 | {todayFiles.map((file) => (
57 |
65 |
66 |
67 | - {file.icon} {file.title}
68 |
69 |
70 |
71 | ))}
72 |
73 |
74 | Last week
75 | {lastWeekFiles.map((file) => (
76 |
84 |
85 |
86 | - {file.icon} {file.title}
87 |
88 |
89 |
90 | ))}
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
98 | const styles = StyleSheet.create({
99 | container: {
100 | flex: 1,
101 | },
102 | section: {
103 | marginBottom: 15,
104 | },
105 | item: {
106 | marginBottom: 8,
107 | },
108 | });
109 |
--------------------------------------------------------------------------------
/app/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, SafeAreaView, Button } from "react-native";
2 |
3 | import { ThemedView } from "@/components/ThemedView";
4 | import { extendedClient } from "@/myDbModule";
5 | import ResentFiles from "@/components/ResentFiles";
6 | import DraggableNotionList from "@/components/DraggableNotionList";
7 |
8 | export default function HomeScreen() {
9 | const user = extendedClient.user.useFindFirst({ where: { id: 1 } });
10 |
11 | const createUser = () => {
12 | const newUser = { name: "Beto", email: "beto@expo.dev" };
13 | extendedClient.user.create({ data: newUser });
14 | };
15 | return (
16 |
17 |
18 | {/* */}
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | const styles = StyleSheet.create({
27 | container: {
28 | flex: 1,
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/app/(tabs)/new-notion.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | StyleSheet,
4 | ScrollView,
5 | useColorScheme,
6 | TextInput,
7 | Keyboard,
8 | View,
9 | InputAccessoryView,
10 | Alert,
11 | Pressable,
12 | Text,
13 | TouchableOpacity,
14 | } from "react-native";
15 |
16 | import { ThemedView } from "@/components/ThemedView";
17 | import { MarkdownTextInput } from "@expensify/react-native-live-markdown";
18 | import { markdownDarkStyle, markdownStyle } from "@/constants/MarkdownStyle";
19 | import { Colors } from "@/constants/Colors";
20 | import { Link, Stack, useLocalSearchParams, useRouter } from "expo-router";
21 | import NotionButton from "@/components/NotionButton";
22 | import { extendedClient } from "@/myDbModule";
23 | import { ThemedText } from "@/components/ThemedText";
24 | import { NotionFile } from "@prisma/client";
25 |
26 | const EXAMPLE_CONTENT = [
27 | "# Insert subtitle here!",
28 | "Hello, *world*! I'm excited to share this with you.",
29 | "Visit my website: codewithbeto.dev",
30 | "> This is a blockquote, a great way to highlight quotes or important notes.",
31 | "`inline code` is useful for highlighting code within a sentence.",
32 | "Here's a code block example:",
33 | "```\n// Codeblock\nconsole.log('🚀 Ready to launch!');\n```",
34 | "Mentions:",
35 | "- @here (notify everyone)",
36 | "- @beto@expo.dev (mention a specific user)",
37 | "Use #hashtags to organize content, like this: #mention-report",
38 | ].join("\n");
39 | const inputAccessoryViewID = "newNotion";
40 | const defaultIcons = [
41 | "🚀",
42 | "👻",
43 | "🎨",
44 | "🎤",
45 | "🥁",
46 | "🎲",
47 | "📱",
48 | "🌟",
49 | "🔥",
50 | "💡",
51 | "🚗",
52 | "🌈",
53 | "📚",
54 | "💻",
55 | "🎧",
56 | "🏆",
57 | "⚽",
58 | "🍔",
59 | "🎂",
60 | "🎵",
61 | "✈️",
62 | "🎮",
63 | "🌍",
64 | "🍕",
65 | "📷",
66 | "📅",
67 | "🔍",
68 | "🔧",
69 | "📝",
70 | "🛠️",
71 | "💼",
72 | "📞",
73 | "📈",
74 | "🏠",
75 | "🎉",
76 | ];
77 |
78 | const randomIcon = () =>
79 | defaultIcons[Math.floor(Math.random() * defaultIcons.length)];
80 |
81 | export default function NewNotionScreen() {
82 | const theme = useColorScheme();
83 | const routeParams = useLocalSearchParams<{
84 | parentId?: string;
85 | viewingFile?: string;
86 | }>();
87 | const router = useRouter();
88 | const viewingFile: NotionFile = routeParams.viewingFile
89 | ? JSON.parse(routeParams.viewingFile)
90 | : null;
91 | const childFiles = extendedClient.notionFile.useFindMany({
92 | where: {
93 | parentFileId: viewingFile?.id ?? -1,
94 | },
95 | });
96 | const parentFile = extendedClient.notionFile.useFindUnique({
97 | where: {
98 | id: viewingFile?.parentFileId ?? -1,
99 | },
100 | });
101 | // console.log(childFiles);
102 | const titleRef = React.useRef(null);
103 | const [title, setTitle] = React.useState(
104 | viewingFile ? viewingFile?.title : ""
105 | );
106 | const [text, setText] = React.useState(
107 | viewingFile ? viewingFile?.content : ""
108 | );
109 | const [icon, setIcon] = React.useState(
110 | viewingFile ? viewingFile?.icon : () => randomIcon()
111 | );
112 |
113 | const backgroundColor = Colors[theme!].background as any;
114 | const textColor = Colors[theme!].text as any;
115 |
116 | React.useEffect(() => {
117 | if (titleRef.current) {
118 | titleRef.current?.focus();
119 | }
120 | }, [theme]);
121 |
122 | function handleSaveNotionFile() {
123 | if (!title) return;
124 | const data = {
125 | title: title,
126 | description: "",
127 | coverPhoto: "",
128 | icon: icon ?? randomIcon(),
129 | content: text,
130 | authorId: 1,
131 | type: "default",
132 | createdAt: new Date().toISOString(),
133 | parentFileId: routeParams.parentId
134 | ? Number(routeParams.parentId)
135 | : viewingFile
136 | ? viewingFile.parentFileId
137 | : null,
138 | };
139 |
140 | try {
141 | if (viewingFile) {
142 | console.log("updating");
143 | extendedClient.notionFile.update({
144 | where: { id: viewingFile.id },
145 | data: data,
146 | });
147 | } else {
148 | console.log("creating");
149 | extendedClient.notionFile.create({
150 | data: data,
151 | });
152 | }
153 |
154 | setTitle("");
155 | setText("");
156 | setIcon(randomIcon());
157 | router.setParams({ parentId: "", viewingFile: "" });
158 | if (router.canDismiss()) {
159 | router.dismissAll();
160 | }
161 | router.replace("/(tabs)/");
162 | } catch (e) {
163 | Alert.alert("Something went wrong :(");
164 | }
165 | }
166 | return (
167 | <>
168 |
175 |
176 |
179 | title ? (
180 |
185 | ) : (
186 | {
189 | router.setParams({ parentId: "", viewingFile: "" });
190 | if (router.canDismiss()) {
191 | router.dismissAll();
192 | }
193 | router.replace("/(tabs)/");
194 | }}
195 | containerStyle={{ marginRight: 10 }}
196 | />
197 | ),
198 | }}
199 | />
200 |
201 |
202 |
203 | {icon && (
204 | {icon}
205 | )}
206 |
216 | {childFiles.length > 0 ? (
217 |
218 | Inner files: {childFiles.length}
219 | {childFiles.map((child) => (
220 |
229 |
230 |
231 | - {child.icon} {child.title}
232 |
233 |
234 |
235 | ))}
236 |
237 | ) : null}
238 |
251 |
252 |
253 |
254 |
258 |
261 |
262 | setText(EXAMPLE_CONTENT)}
266 | />
267 | {}} />
268 |
269 | {/* */}
275 | {defaultIcons.slice(0, 6).map((icon) => (
276 | setIcon(icon)}>
277 | {icon}
278 |
279 | ))}
280 | {/* */}
281 | Keyboard.dismiss()}
284 | />
285 |
286 |
287 |
288 | >
289 | );
290 | }
291 |
292 | const styles = StyleSheet.create({
293 | container: { padding: 10 },
294 | accesoryView: {
295 | flexDirection: "row",
296 | alignItems: "center",
297 | justifyContent: "space-between",
298 | paddingHorizontal: 7,
299 | height: 50,
300 | borderTopWidth: 0.5,
301 | },
302 | });
303 |
--------------------------------------------------------------------------------
/app/+html.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollViewStyleReset } from 'expo-router/html';
2 | import { type PropsWithChildren } from 'react';
3 |
4 | /**
5 | * This file is web-only and used to configure the root HTML for every web page during static rendering.
6 | * The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs.
7 | */
8 | export default function Root({ children }: PropsWithChildren) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 | {/*
17 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
18 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
19 | */}
20 |
21 |
22 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
23 |
24 | {/* Add any additional elements that you want globally available on web... */}
25 |
26 | {children}
27 |
28 | );
29 | }
30 |
31 | const responsiveBackground = `
32 | body {
33 | background-color: #fff;
34 | }
35 | @media (prefers-color-scheme: dark) {
36 | body {
37 | background-color: #000;
38 | }
39 | }`;
40 |
--------------------------------------------------------------------------------
/app/+not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from 'expo-router';
2 | import { StyleSheet } from 'react-native';
3 |
4 | import { ThemedText } from '@/components/ThemedText';
5 | import { ThemedView } from '@/components/ThemedView';
6 |
7 | export default function NotFoundScreen() {
8 | return (
9 | <>
10 |
11 |
12 | This screen doesn't exist.
13 |
14 | Go to home screen!
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | const styles = StyleSheet.create({
22 | container: {
23 | flex: 1,
24 | alignItems: 'center',
25 | justifyContent: 'center',
26 | padding: 20,
27 | },
28 | link: {
29 | marginTop: 15,
30 | paddingVertical: 15,
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from "@react-navigation/native";
6 | import { useFonts } from "expo-font";
7 | import { Stack } from "expo-router";
8 | import * as SplashScreen from "expo-splash-screen";
9 | import { useEffect, useState } from "react";
10 | import "react-native-reanimated";
11 |
12 | import { useColorScheme } from "@/hooks/useColorScheme";
13 | import { initializeDb } from "@/myDbModule";
14 | import { GestureHandlerRootView } from "react-native-gesture-handler";
15 | import { ActionSheetProvider } from "@expo/react-native-action-sheet";
16 |
17 | // Prevent the splash screen from auto-hiding before asset loading is complete.
18 | SplashScreen.preventAutoHideAsync();
19 |
20 | export default function RootLayout() {
21 | const colorScheme = useColorScheme();
22 | const [loaded] = useFonts({
23 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
24 | });
25 | const [isLoading, setIsLoading] = useState(true);
26 |
27 | useEffect(() => {
28 | const setup = async () => {
29 | try {
30 | await initializeDb();
31 | } catch (e) {
32 | console.log(e);
33 | }
34 | setIsLoading(false);
35 | };
36 |
37 | setup();
38 | }, []);
39 |
40 | useEffect(() => {
41 | if (loaded) {
42 | SplashScreen.hideAsync();
43 | }
44 | }, [loaded]);
45 |
46 | if (!loaded && !isLoading) {
47 | return null;
48 | }
49 |
50 | return (
51 |
52 |
53 |
56 |
57 |
58 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/app/doc-actions-sheet.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from "expo-router";
2 | import { StyleSheet } from "react-native";
3 |
4 | import { ThemedText } from "@/components/ThemedText";
5 | import { ThemedView } from "@/components/ThemedView";
6 |
7 | export default function DocActionsSheetScreen() {
8 | return (
9 | <>
10 |
11 |
12 | This screen doesn't exist.
13 |
14 | Go to home screen!
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | const styles = StyleSheet.create({
22 | container: {
23 | flex: 1,
24 | alignItems: "center",
25 | justifyContent: "center",
26 | padding: 20,
27 | },
28 | link: {
29 | marginTop: 15,
30 | paddingVertical: 15,
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/app/new-notion.tsx:
--------------------------------------------------------------------------------
1 | import NewNotionScreen from "./(tabs)/new-notion";
2 |
3 | export default NewNotionScreen;
4 |
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/React-Native-Notion-Clone/24d83b54cf73e27b63a0d37ec5e432e3fe49cbc8/assets/images/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/components/Collapsible.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from "@expo/vector-icons/Ionicons";
2 | import { PropsWithChildren, useState } from "react";
3 | import { StyleSheet, TouchableOpacity, useColorScheme } from "react-native";
4 |
5 | import { ThemedText } from "@/components/ThemedText";
6 | import { ThemedView } from "@/components/ThemedView";
7 | import { Colors } from "@/constants/Colors";
8 |
9 | export function Collapsible({
10 | children,
11 | title,
12 | }: PropsWithChildren & { title: string }) {
13 | const [isOpen, setIsOpen] = useState(false);
14 | const theme = useColorScheme() ?? "light";
15 |
16 | return (
17 |
18 | setIsOpen((value) => !value)}
21 | activeOpacity={0.8}
22 | >
23 |
28 | {title}
29 |
30 | {isOpen && {children}}
31 |
32 | );
33 | }
34 |
35 | const styles = StyleSheet.create({
36 | heading: {
37 | flexDirection: "row",
38 | alignItems: "center",
39 | gap: 6,
40 | },
41 | content: {
42 | marginTop: 6,
43 | marginLeft: 24,
44 | },
45 | });
46 |
--------------------------------------------------------------------------------
/components/DraggableNotionList.tsx:
--------------------------------------------------------------------------------
1 | import { baseClient, extendedClient } from "@/myDbModule";
2 | import { NotionFile } from "@prisma/client/react-native";
3 | import React, { useEffect, useState } from "react";
4 | import { TouchableOpacity, View } from "react-native";
5 | import DraggableFlatList from "react-native-draggable-flatlist";
6 | import { DraggableNotionListItem } from "./DraggableNotionListItem";
7 | import { ThemedText } from "./ThemedText";
8 | import { Ionicons } from "@expo/vector-icons";
9 | import { useActionSheet } from "@expo/react-native-action-sheet";
10 | import { Prisma } from "@prisma/client";
11 |
12 | export default function DraggableNotionList() {
13 | const { showActionSheetWithOptions } = useActionSheet();
14 | const [sortedFiles, setSortedFiles] = useState([]);
15 | const [orderBy, setOrderBy] =
16 | useState({ order: "asc" });
17 |
18 | const files = extendedClient.notionFile.useFindMany({
19 | where: { parentFile: { is: null } }, // prevent fetching files that live inside files
20 | orderBy: orderBy,
21 | });
22 |
23 | useEffect(() => {
24 | setSortedFiles(files);
25 | }, [files, orderBy]);
26 |
27 | const handleActionSheet = () => {
28 | const options = ["Manual", "Creation Date", "Cancel"];
29 | const cancelButtonIndex = 2;
30 |
31 | showActionSheetWithOptions(
32 | {
33 | options,
34 | cancelButtonIndex,
35 | },
36 | (selectedIndex: number | undefined) => {
37 | switch (selectedIndex) {
38 | case 0: {
39 | // Manual
40 | setOrderBy({ order: "asc" });
41 | break;
42 | }
43 | case 1: {
44 | // Creation Date
45 | setOrderBy({ createdAt: "desc" });
46 | }
47 | }
48 | }
49 | );
50 | };
51 |
52 | const handleDragEnd = async (data: NotionFile[]) => {
53 | setSortedFiles(data);
54 | const updates = data.map((file, index) => {
55 | return baseClient.notionFile.update({
56 | where: { id: file.id },
57 | data: { order: index },
58 | });
59 | });
60 | await baseClient.$transaction(updates);
61 | await extendedClient.$refreshSubscriptions();
62 |
63 | // data.forEach((file, index) => {
64 | // extendedClient.notionFile.update({
65 | // where: { id: file.id },
66 | // data: { order: index },
67 | // });
68 | // });
69 | };
70 |
71 | return (
72 | handleDragEnd(data)}
76 | keyExtractor={(item) => item.id.toString()}
77 | renderItem={DraggableNotionListItem}
78 | ListHeaderComponent={() => (
79 | <>
80 | {/* */}
81 |
89 |
90 | Private Files
91 |
92 |
96 |
97 |
98 |
99 |
100 | {!sortedFiles.length && (
101 |
105 | Nothing to show!
106 |
107 | )}
108 | >
109 | )}
110 | />
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/components/DraggableNotionListItem.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from "@expo/vector-icons/Ionicons";
2 | import { useState } from "react";
3 | import {
4 | Pressable,
5 | StyleSheet,
6 | TouchableOpacity,
7 | View,
8 | useColorScheme,
9 | } from "react-native";
10 |
11 | import { ThemedText } from "@/components/ThemedText";
12 | import { Colors } from "@/constants/Colors";
13 | import { RenderItemParams } from "react-native-draggable-flatlist";
14 | import { NotionFile } from "@prisma/client/react-native";
15 | import { useActionSheet } from "@expo/react-native-action-sheet";
16 | import { extendedClient } from "@/myDbModule";
17 | import { Link } from "expo-router";
18 |
19 | export function DraggableNotionListItem({
20 | drag,
21 | isActive,
22 | item,
23 | }: RenderItemParams) {
24 | const theme = useColorScheme() ?? "light";
25 | const iconColor = theme === "light" ? Colors.light.icon : Colors.dark.icon;
26 |
27 | return (
28 |
34 | );
35 | }
36 |
37 | interface InnerNotionListItemProps {
38 | parentId: number | undefined;
39 | }
40 | function InnerNotionListItem({ parentId }: InnerNotionListItemProps) {
41 | const theme = useColorScheme() ?? "light";
42 | const iconColor = theme === "light" ? Colors.light.icon : Colors.dark.icon;
43 |
44 | const childs = extendedClient.notionFile.useFindMany({
45 | where: { parentFileId: parentId },
46 | });
47 |
48 | if (childs.length === 0)
49 | return No pages inside;
50 | return (
51 |
52 | {childs.map((notionFile: NotionFile) => (
53 |
58 | ))}
59 |
60 | );
61 | }
62 |
63 | interface NotionFileItemProps {
64 | notionFile: NotionFile;
65 | iconColor: string;
66 | drag?: () => void;
67 | isActive?: boolean;
68 | }
69 |
70 | function NotionFileItem({
71 | notionFile,
72 | iconColor,
73 | drag,
74 | isActive,
75 | }: NotionFileItemProps) {
76 | const { showActionSheetWithOptions } = useActionSheet();
77 | const [isOpen, setIsOpen] = useState(false);
78 |
79 | const onPress = (id: number) => {
80 | const options = ["Delete", "Cancel"];
81 | const destructiveButtonIndex = 0;
82 | const cancelButtonIndex = 1;
83 |
84 | showActionSheetWithOptions(
85 | {
86 | options,
87 | cancelButtonIndex,
88 | destructiveButtonIndex,
89 | },
90 | (selectedIndex: number | undefined) => {
91 | switch (selectedIndex) {
92 | case destructiveButtonIndex: {
93 | extendedClient.notionFile.delete({
94 | where: {
95 | id: id,
96 | },
97 | });
98 | break;
99 | }
100 | case cancelButtonIndex: {
101 | // Canceled
102 | }
103 | }
104 | }
105 | );
106 | };
107 |
108 | return (
109 |
110 |
118 |
124 |
125 | setIsOpen((value) => !value)}>
126 |
132 |
133 |
134 | {notionFile.icon} {notionFile.title}
135 |
136 |
137 |
138 | onPress(notionFile.id)}>
139 |
144 |
145 |
151 |
152 |
153 |
154 |
155 |
156 | {isOpen ? (
157 |
158 |
159 |
160 | ) : null}
161 |
162 | );
163 | }
164 |
165 | const styles = StyleSheet.create({
166 | heading: {
167 | height: 40,
168 | flexDirection: "row",
169 | alignItems: "center",
170 | justifyContent: "space-between",
171 | paddingHorizontal: 12,
172 | },
173 | content: {
174 | marginLeft: 24,
175 | },
176 | });
177 |
--------------------------------------------------------------------------------
/components/ExternalLink.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'expo-router';
2 | import { openBrowserAsync } from 'expo-web-browser';
3 | import { type ComponentProps } from 'react';
4 | import { Platform } from 'react-native';
5 |
6 | type Props = Omit, 'href'> & { href: string };
7 |
8 | export function ExternalLink({ href, ...rest }: Props) {
9 | return (
10 | {
15 | if (Platform.OS !== 'web') {
16 | // Prevent the default behavior of linking to the default browser on native.
17 | event.preventDefault();
18 | // Open the link in an in-app browser.
19 | await openBrowserAsync(href);
20 | }
21 | }}
22 | />
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/HelloWave.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet } from 'react-native';
2 | import Animated, {
3 | useSharedValue,
4 | useAnimatedStyle,
5 | withTiming,
6 | withRepeat,
7 | withSequence,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedText } from '@/components/ThemedText';
11 |
12 | export function HelloWave() {
13 | const rotationAnimation = useSharedValue(0);
14 |
15 | rotationAnimation.value = withRepeat(
16 | withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
17 | 4 // Run the animation 4 times
18 | );
19 |
20 | const animatedStyle = useAnimatedStyle(() => ({
21 | transform: [{ rotate: `${rotationAnimation.value}deg` }],
22 | }));
23 |
24 | return (
25 |
26 | 👋
27 |
28 | );
29 | }
30 |
31 | const styles = StyleSheet.create({
32 | text: {
33 | fontSize: 28,
34 | lineHeight: 32,
35 | marginTop: -6,
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/components/NotionButton.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 | import { Ionicons } from "@expo/vector-icons";
3 | import {
4 | StyleProp,
5 | StyleSheet,
6 | TouchableOpacity,
7 | View,
8 | ViewStyle,
9 | useColorScheme,
10 | } from "react-native";
11 | import { ThemedText } from "./ThemedText";
12 | import { Colors } from "@/constants/Colors";
13 |
14 | interface NotionButtonProps {
15 | onPress: () => void;
16 | title?: string;
17 | iconName?: ComponentProps["name"];
18 | containerStyle?: StyleProp;
19 | }
20 | export default function NotionButton({
21 | onPress,
22 | iconName,
23 | title,
24 | containerStyle,
25 | }: NotionButtonProps) {
26 | const theme = useColorScheme();
27 | return (
28 |
39 | {iconName && (
40 |
41 | )}
42 | {title && (
43 |
48 | {title}
49 |
50 | )}
51 |
52 | );
53 | }
54 |
55 | const styles = StyleSheet.create({
56 | container: {
57 | padding: 7,
58 | borderRadius: 40,
59 | flexDirection: "row",
60 | alignItems: "center",
61 | gap: 7,
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/components/ParallaxScrollView.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren, ReactElement } from 'react';
2 | import { StyleSheet, useColorScheme } from 'react-native';
3 | import Animated, {
4 | interpolate,
5 | useAnimatedRef,
6 | useAnimatedStyle,
7 | useScrollViewOffset,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedView } from '@/components/ThemedView';
11 |
12 | const HEADER_HEIGHT = 250;
13 |
14 | type Props = PropsWithChildren<{
15 | headerImage: ReactElement;
16 | headerBackgroundColor: { dark: string; light: string };
17 | }>;
18 |
19 | export default function ParallaxScrollView({
20 | children,
21 | headerImage,
22 | headerBackgroundColor,
23 | }: Props) {
24 | const colorScheme = useColorScheme() ?? 'light';
25 | const scrollRef = useAnimatedRef();
26 | const scrollOffset = useScrollViewOffset(scrollRef);
27 |
28 | const headerAnimatedStyle = useAnimatedStyle(() => {
29 | return {
30 | transform: [
31 | {
32 | translateY: interpolate(
33 | scrollOffset.value,
34 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
35 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
36 | ),
37 | },
38 | {
39 | scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
40 | },
41 | ],
42 | };
43 | });
44 |
45 | return (
46 |
47 |
48 |
54 | {headerImage}
55 |
56 | {children}
57 |
58 |
59 | );
60 | }
61 |
62 | const styles = StyleSheet.create({
63 | container: {
64 | flex: 1,
65 | },
66 | header: {
67 | height: 250,
68 | overflow: 'hidden',
69 | },
70 | content: {
71 | flex: 1,
72 | padding: 32,
73 | gap: 16,
74 | overflow: 'hidden',
75 | },
76 | });
77 |
--------------------------------------------------------------------------------
/components/ResentFileCard.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, TouchableOpacity, useColorScheme } from "react-native";
2 |
3 | import { ThemedText } from "@/components/ThemedText";
4 | import { extendedClient } from "@/myDbModule";
5 | import { Colors } from "@/constants/Colors";
6 | import { LinearGradient } from "expo-linear-gradient";
7 | import { getRandomGradient } from "@/constants/Gradients";
8 | import { useMemo } from "react";
9 | import Animated, {
10 | LinearTransition,
11 | SlideInLeft,
12 | SlideOutLeft,
13 | } from "react-native-reanimated";
14 | import { NotionFile } from "@prisma/client/react-native";
15 | import { useNavigation } from "expo-router";
16 |
17 | export default function ResentFileCard(file: NotionFile) {
18 | const navigation = useNavigation();
19 | const theme = useColorScheme();
20 | const color = useMemo(() => getRandomGradient(), []);
21 | const { id, title, coverPhoto, icon } = file;
22 | return (
23 |
36 |
38 | // @ts-ignore
39 | navigation.navigate("new-notion", {
40 | viewingFile: JSON.stringify(file),
41 | })
42 | }
43 | onLongPress={() => extendedClient.notionFile.delete({ where: { id } })}
44 | style={[
45 | styles.container,
46 | {
47 | backgroundColor: Colors[theme!].backgroundSecondary,
48 | borderColor: Colors[theme!].text + "20",
49 | },
50 | ]}
51 | >
52 | {coverPhoto ? (
53 | <>>
54 | ) : (
55 |
63 | )}
64 | {icon}
65 |
74 | {title}
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | const styles = StyleSheet.create({
82 | container: {
83 | width: 120,
84 | height: 120,
85 | borderRadius: 16,
86 | borderWidth: 0.5,
87 | },
88 | icon: {
89 | fontSize: 32,
90 | lineHeight: 36,
91 | position: "absolute",
92 | top: 40,
93 | left: 6,
94 | },
95 | });
96 |
--------------------------------------------------------------------------------
/components/ResentFiles.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, ScrollView, useColorScheme, View } from "react-native";
2 |
3 | import { ThemedText } from "@/components/ThemedText";
4 | import { extendedClient } from "@/myDbModule";
5 | import ResentFileCard from "./ResentFileCard";
6 | import { Colors } from "@/constants/Colors";
7 |
8 | export default function ResentFiles() {
9 | const theme = useColorScheme();
10 | const files = extendedClient.notionFile.useFindMany({
11 | orderBy: { createdAt: "desc" },
12 | take: 6,
13 | where: { parentFileId: { equals: null } },
14 | });
15 |
16 | return (
17 |
18 |
25 | Jump back in
26 |
27 | {!files.length && (
28 |
32 | Nothing to show!
33 |
34 | )}
35 |
43 | {files.map((file) => (
44 |
45 | ))}
46 |
47 |
48 | );
49 | }
50 |
51 | const styles = StyleSheet.create({
52 | container: {
53 | marginBottom: 10,
54 | },
55 | });
56 |
--------------------------------------------------------------------------------
/components/ThemedText.tsx:
--------------------------------------------------------------------------------
1 | import { Text, type TextProps, StyleSheet } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedTextProps = TextProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
9 | };
10 |
11 | export function ThemedText({
12 | style,
13 | lightColor,
14 | darkColor,
15 | type = 'default',
16 | ...rest
17 | }: ThemedTextProps) {
18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
19 |
20 | return (
21 |
33 | );
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | default: {
38 | fontSize: 16,
39 | lineHeight: 24,
40 | },
41 | defaultSemiBold: {
42 | fontSize: 16,
43 | lineHeight: 24,
44 | fontWeight: '600',
45 | },
46 | title: {
47 | fontSize: 32,
48 | fontWeight: 'bold',
49 | lineHeight: 32,
50 | },
51 | subtitle: {
52 | fontSize: 20,
53 | fontWeight: 'bold',
54 | },
55 | link: {
56 | lineHeight: 30,
57 | fontSize: 16,
58 | color: '#0a7ea4',
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/components/ThemedView.tsx:
--------------------------------------------------------------------------------
1 | import { View, type ViewProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedViewProps = ViewProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | };
9 |
10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/components/__tests__/ThemedText-test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import renderer from 'react-test-renderer';
3 |
4 | import { ThemedText } from '../ThemedText';
5 |
6 | it(`renders correctly`, () => {
7 | const tree = renderer.create(Snapshot test!).toJSON();
8 |
9 | expect(tree).toMatchSnapshot();
10 | });
11 |
--------------------------------------------------------------------------------
/components/__tests__/__snapshots__/ThemedText-test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders correctly 1`] = `
4 |
22 | Snapshot test!
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/components/navigation/TabBarIcon.tsx:
--------------------------------------------------------------------------------
1 | // You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
2 |
3 | import Ionicons from '@expo/vector-icons/Ionicons';
4 | import { type IconProps } from '@expo/vector-icons/build/createIconSet';
5 | import { type ComponentProps } from 'react';
6 |
7 | export function TabBarIcon({ style, ...rest }: IconProps['name']>) {
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/constants/Colors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 | */
5 |
6 | const tintColorLight = "#000";
7 | const tintColorDark = "#fff";
8 |
9 | export const Colors = {
10 | light: {
11 | text: "#11181C",
12 | background: "#fff",
13 | backgroundSecondary: "#00000005",
14 | tint: tintColorLight,
15 | icon: "#687076",
16 | tabIconDefault: "#687076",
17 | tabIconSelected: tintColorLight,
18 | },
19 | dark: {
20 | text: "#ECEDEE",
21 | background: "#151718",
22 | backgroundSecondary: "#ffffff05",
23 | tint: tintColorDark,
24 | icon: "#9BA1A6",
25 | tabIconDefault: "#9BA1A6",
26 | tabIconSelected: tintColorDark,
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/constants/Gradients.ts:
--------------------------------------------------------------------------------
1 | import { LinearGradientProps } from "expo-linear-gradient";
2 |
3 | const gradients = [
4 | { name: "Oceanic", colors: ["#0093E9", "#80D0C7"] },
5 | { name: "Cotton Candy", colors: ["#D9AFD9", "#97D9E1"] },
6 | { name: "Sunset", colors: ["#FAD961", "#F76B1C"] },
7 | { name: "Beachside", colors: ["#00CDAC", "#02AAB0"] },
8 | { name: "Peachy", colors: ["#FFA751", "#FFE259"] },
9 | { name: "Pumpkin", colors: ["#D4145A", "#FBB03B"] },
10 | { name: "Arendelle", colors: ["#9796f0", "#fbc7d4"] },
11 | { name: "High Tide", colors: ["#0082C8", "#667DB6"] },
12 | ];
13 |
14 | export function getRandomGradient(): LinearGradientProps["colors"] {
15 | const randomIndex = Math.floor(Math.random() * gradients.length);
16 | return gradients[randomIndex].colors;
17 | }
18 |
--------------------------------------------------------------------------------
/constants/MarkdownStyle.ts:
--------------------------------------------------------------------------------
1 | import { Platform } from "react-native";
2 | import type { MarkdownStyle } from "@expensify/react-native-live-markdown";
3 | import { Colors } from "./Colors";
4 |
5 | const FONT_FAMILY_MONOSPACE = Platform.select({
6 | ios: "Courier",
7 | default: "monospace",
8 | });
9 |
10 | export const markdownStyle: MarkdownStyle = {
11 | syntax: {
12 | color: Colors.light.text,
13 | },
14 | link: {
15 | color: Colors.light.tint,
16 | },
17 | h1: {
18 | fontSize: 22,
19 | },
20 | emoji: {
21 | fontSize: 32,
22 | },
23 | blockquote: {
24 | borderColor: Colors.light.tint,
25 | borderWidth: 2,
26 | marginLeft: 6,
27 | paddingLeft: 6,
28 | },
29 | code: {
30 | fontFamily: FONT_FAMILY_MONOSPACE,
31 | fontSize: 16,
32 | color: Colors.light.text,
33 | backgroundColor: "#00000010",
34 | },
35 | pre: {
36 | fontFamily: FONT_FAMILY_MONOSPACE,
37 | fontSize: 16,
38 | color: Colors.light.text,
39 | backgroundColor: "#00000010",
40 | },
41 | mentionHere: {
42 | color: Colors.light.text,
43 | backgroundColor: "yellow",
44 | },
45 | mentionUser: {
46 | color: Colors.light.text,
47 | backgroundColor: "cyan",
48 | },
49 | };
50 |
51 | export const markdownDarkStyle: MarkdownStyle = {
52 | syntax: {
53 | color: Colors.dark.text,
54 | },
55 | link: {
56 | color: Colors.dark.tint,
57 | },
58 | h1: {
59 | fontSize: 22,
60 | },
61 | emoji: {
62 | fontSize: 32,
63 | },
64 | blockquote: {
65 | borderColor: Colors.dark.tint,
66 | borderWidth: 2,
67 | marginLeft: 6,
68 | paddingLeft: 6,
69 | },
70 | code: {
71 | fontFamily: FONT_FAMILY_MONOSPACE,
72 | fontSize: 16,
73 | color: Colors.dark.tint,
74 | backgroundColor: "#ffffff10",
75 | },
76 | pre: {
77 | fontFamily: FONT_FAMILY_MONOSPACE,
78 | fontSize: 16,
79 | color: Colors.dark.text,
80 | backgroundColor: "#00000010",
81 | },
82 | mentionHere: {
83 | color: Colors.light.text,
84 | backgroundColor: "yellow",
85 | },
86 | mentionUser: {
87 | color: "white",
88 | backgroundColor: "#0891b2",
89 | },
90 | };
91 |
--------------------------------------------------------------------------------
/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 10.2.4"
4 | },
5 | "build": {
6 | "development": {
7 | "developmentClient": true,
8 | "distribution": "internal"
9 | },
10 | "preview": {
11 | "distribution": "internal"
12 | },
13 | "production": {}
14 | },
15 | "submit": {
16 | "production": {}
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.ts:
--------------------------------------------------------------------------------
1 | export { useColorScheme } from 'react-native';
2 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.web.ts:
--------------------------------------------------------------------------------
1 | // NOTE: The default React Native styling doesn't support server rendering.
2 | // Server rendered styles should not change between the first render of the HTML
3 | // and the first render on the client. Typically, web developers will use CSS media queries
4 | // to render different styles on the client and server, these aren't directly supported in React Native
5 | // but can be achieved using a styling library like Nativewind.
6 | export function useColorScheme() {
7 | return 'light';
8 | }
9 |
--------------------------------------------------------------------------------
/hooks/useThemeColor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Learn more about light and dark modes:
3 | * https://docs.expo.dev/guides/color-schemes/
4 | */
5 |
6 | import { useColorScheme } from 'react-native';
7 |
8 | import { Colors } from '@/constants/Colors';
9 |
10 | export function useThemeColor(
11 | props: { light?: string; dark?: string },
12 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark
13 | ) {
14 | const theme = useColorScheme() ?? 'light';
15 | const colorFromProps = props[theme];
16 |
17 | if (colorFromProps) {
18 | return colorFromProps;
19 | } else {
20 | return Colors[theme][colorName];
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/migrations/20240703032200_one/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4 | "name" TEXT NOT NULL,
5 | "email" TEXT NOT NULL DEFAULT '',
6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
7 | "updatedAt" DATETIME NOT NULL
8 | );
9 |
10 | -- CreateTable
11 | CREATE TABLE "NotionFile" (
12 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
13 | "coverPhoto" TEXT NOT NULL,
14 | "icon" TEXT NOT NULL,
15 | "title" TEXT NOT NULL,
16 | "description" TEXT NOT NULL,
17 | "content" TEXT NOT NULL,
18 | "type" TEXT NOT NULL,
19 | "authorId" INTEGER NOT NULL,
20 | "parentFileId" INTEGER,
21 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
22 | "updatedAt" DATETIME NOT NULL,
23 | CONSTRAINT "NotionFile_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
24 | CONSTRAINT "NotionFile_parentFileId_fkey" FOREIGN KEY ("parentFileId") REFERENCES "NotionFile" ("id") ON DELETE SET NULL ON UPDATE CASCADE
25 | );
26 |
--------------------------------------------------------------------------------
/migrations/20240703215900_add_order_to_notionfile/migration.sql:
--------------------------------------------------------------------------------
1 | -- RedefineTables
2 | PRAGMA defer_foreign_keys=ON;
3 | PRAGMA foreign_keys=OFF;
4 | CREATE TABLE "new_NotionFile" (
5 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
6 | "coverPhoto" TEXT NOT NULL,
7 | "icon" TEXT NOT NULL,
8 | "title" TEXT NOT NULL,
9 | "description" TEXT NOT NULL,
10 | "content" TEXT NOT NULL,
11 | "type" TEXT NOT NULL,
12 | "authorId" INTEGER NOT NULL,
13 | "parentFileId" INTEGER,
14 | "order" INTEGER NOT NULL DEFAULT 0,
15 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
16 | "updatedAt" DATETIME NOT NULL,
17 | CONSTRAINT "NotionFile_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
18 | CONSTRAINT "NotionFile_parentFileId_fkey" FOREIGN KEY ("parentFileId") REFERENCES "NotionFile" ("id") ON DELETE SET NULL ON UPDATE CASCADE
19 | );
20 | INSERT INTO "new_NotionFile" ("authorId", "content", "coverPhoto", "createdAt", "description", "icon", "id", "parentFileId", "title", "type", "updatedAt") SELECT "authorId", "content", "coverPhoto", "createdAt", "description", "icon", "id", "parentFileId", "title", "type", "updatedAt" FROM "NotionFile";
21 | DROP TABLE "NotionFile";
22 | ALTER TABLE "new_NotionFile" RENAME TO "NotionFile";
23 | PRAGMA foreign_keys=ON;
24 | PRAGMA defer_foreign_keys=OFF;
25 |
--------------------------------------------------------------------------------
/migrations/20240704014730_add_on_delete_cascade_for_notion_files/migration.sql:
--------------------------------------------------------------------------------
1 | -- RedefineTables
2 | PRAGMA defer_foreign_keys=ON;
3 | PRAGMA foreign_keys=OFF;
4 | CREATE TABLE "new_NotionFile" (
5 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
6 | "coverPhoto" TEXT NOT NULL,
7 | "icon" TEXT NOT NULL,
8 | "title" TEXT NOT NULL,
9 | "description" TEXT NOT NULL,
10 | "content" TEXT NOT NULL,
11 | "type" TEXT NOT NULL,
12 | "authorId" INTEGER NOT NULL,
13 | "parentFileId" INTEGER,
14 | "order" INTEGER NOT NULL DEFAULT 0,
15 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
16 | "updatedAt" DATETIME NOT NULL,
17 | CONSTRAINT "NotionFile_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
18 | CONSTRAINT "NotionFile_parentFileId_fkey" FOREIGN KEY ("parentFileId") REFERENCES "NotionFile" ("id") ON DELETE CASCADE ON UPDATE CASCADE
19 | );
20 | INSERT INTO "new_NotionFile" ("authorId", "content", "coverPhoto", "createdAt", "description", "icon", "id", "order", "parentFileId", "title", "type", "updatedAt") SELECT "authorId", "content", "coverPhoto", "createdAt", "description", "icon", "id", "order", "parentFileId", "title", "type", "updatedAt" FROM "NotionFile";
21 | DROP TABLE "NotionFile";
22 | ALTER TABLE "new_NotionFile" RENAME TO "NotionFile";
23 | PRAGMA foreign_keys=ON;
24 | PRAGMA defer_foreign_keys=OFF;
25 |
--------------------------------------------------------------------------------
/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "sqlite"
--------------------------------------------------------------------------------
/myDbModule.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client/react-native";
2 | import { reactiveHooksExtension } from "@prisma/react-native";
3 |
4 | export const baseClient = new PrismaClient({
5 | log: [
6 | // { emit: "stdout", level: "query" },
7 | { emit: "stdout", level: "info" },
8 | { emit: "stdout", level: "warn" },
9 | { emit: "stdout", level: "error" },
10 | ],
11 | });
12 |
13 | export const extendedClient = baseClient.$extends(reactiveHooksExtension());
14 |
15 | export async function initializeDb() {
16 | try {
17 | await baseClient.$applyPendingMigrations();
18 | console.log("db initialized!");
19 | } catch (e) {
20 | console.error(`failed to apply migrations: ${e}`);
21 | throw new Error(
22 | "Applying migrations failed, your app is now in an inconsistent state. We cannot guarantee safety, it is now your responsibility to reset the database or tell the user to re-install the app"
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "notion-clone",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web",
11 | "test": "jest --watchAll",
12 | "lint": "expo lint"
13 | },
14 | "jest": {
15 | "preset": "jest-expo"
16 | },
17 | "dependencies": {
18 | "@expensify/react-native-live-markdown": "^0.1.97",
19 | "@expo/react-native-action-sheet": "^4.1.0",
20 | "@expo/vector-icons": "^14.0.2",
21 | "@prisma/client": "5.16.1",
22 | "@prisma/react-native": "5.16.1",
23 | "@react-navigation/native": "^6.0.2",
24 | "expo": "~51.0.28",
25 | "expo-constants": "~16.0.2",
26 | "expo-dev-client": "~4.0.23",
27 | "expo-font": "~12.0.9",
28 | "expo-linear-gradient": "~13.0.2",
29 | "expo-linking": "~6.3.1",
30 | "expo-router": "~3.5.23",
31 | "expo-splash-screen": "~0.27.5",
32 | "expo-status-bar": "~1.12.1",
33 | "expo-system-ui": "~3.0.7",
34 | "expo-web-browser": "~13.0.3",
35 | "react": "18.2.0",
36 | "react-dom": "18.2.0",
37 | "react-native": "0.74.5",
38 | "react-native-draggable-flatlist": "^4.0.1",
39 | "react-native-gesture-handler": "~2.16.1",
40 | "react-native-quick-base64": "2.1.2",
41 | "react-native-reanimated": "~3.10.1",
42 | "react-native-safe-area-context": "4.10.5",
43 | "react-native-screens": "3.31.1",
44 | "react-native-web": "~0.19.10"
45 | },
46 | "devDependencies": {
47 | "@babel/core": "^7.20.0",
48 | "@types/jest": "^29.5.12",
49 | "@types/react": "~18.2.45",
50 | "@types/react-test-renderer": "^18.0.7",
51 | "jest": "^29.2.1",
52 | "jest-expo": "~51.0.3",
53 | "react-test-renderer": "18.2.0",
54 | "typescript": "~5.3.3"
55 | },
56 | "private": true
57 | }
58 |
--------------------------------------------------------------------------------
/queries.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO User (name) VALUES ('John Doe');
2 | INSERT INTO NotionFile (coverPhoto, icon, title, description, content, type, authorId)
3 | VALUES ('https://i.ytimg.com/vi/XoumiwHbKDg/maxresdefault.jpg', '🔥', 'Snapchat Clone', 'Sample Description', 'Sample Content', 'text', 1);
4 |
--------------------------------------------------------------------------------
/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | previewFeatures = ["reactNative"]
4 | }
5 |
6 | datasource db {
7 | provider = "sqlite"
8 | url = "file:./app.db"
9 | }
10 |
11 | model User {
12 | id Int @id @default(autoincrement())
13 | name String
14 | email String @default("")
15 | notionFiles NotionFile[]
16 | createdAt DateTime @default(now())
17 | updatedAt DateTime @updatedAt
18 | }
19 |
20 | model NotionFile {
21 | id Int @id @default(autoincrement())
22 | coverPhoto String
23 | icon String
24 | title String
25 | description String
26 | content String
27 | type String
28 | authorId Int
29 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
30 | parentFileId Int? // Nullable to allow top-level files
31 | parentFile NotionFile? @relation("FileHierarchy", fields: [parentFileId], references: [id], onDelete: Cascade)
32 | subFiles NotionFile[] @relation("FileHierarchy")
33 | order Int @default(0)
34 | createdAt DateTime @default(now())
35 | updatedAt DateTime @updatedAt
36 | }
37 |
--------------------------------------------------------------------------------
/scripts/reset-project.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * This script is used to reset the project to a blank state.
5 | * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it.
7 | */
8 |
9 | const fs = require('fs');
10 | const path = require('path');
11 |
12 | const root = process.cwd();
13 | const oldDirPath = path.join(root, 'app');
14 | const newDirPath = path.join(root, 'app-example');
15 | const newAppDirPath = path.join(root, 'app');
16 |
17 | const indexContent = `import { Text, View } from "react-native";
18 |
19 | export default function Index() {
20 | return (
21 |
28 | Edit app/index.tsx to edit this screen.
29 |
30 | );
31 | }
32 | `;
33 |
34 | const layoutContent = `import { Stack } from "expo-router";
35 |
36 | export default function RootLayout() {
37 | return (
38 |
39 |
40 |
41 | );
42 | }
43 | `;
44 |
45 | fs.rename(oldDirPath, newDirPath, (error) => {
46 | if (error) {
47 | return console.error(`Error renaming directory: ${error}`);
48 | }
49 | console.log('/app moved to /app-example.');
50 |
51 | fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
52 | if (error) {
53 | return console.error(`Error creating new app directory: ${error}`);
54 | }
55 | console.log('New /app directory created.');
56 |
57 | const indexPath = path.join(newAppDirPath, 'index.tsx');
58 | fs.writeFile(indexPath, indexContent, (error) => {
59 | if (error) {
60 | return console.error(`Error creating index.tsx: ${error}`);
61 | }
62 | console.log('app/index.tsx created.');
63 |
64 | const layoutPath = path.join(newAppDirPath, '_layout.tsx');
65 | fs.writeFile(layoutPath, layoutContent, (error) => {
66 | if (error) {
67 | return console.error(`Error creating _layout.tsx: ${error}`);
68 | }
69 | console.log('app/_layout.tsx created.');
70 | });
71 | });
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------