This commit is contained in:
Morten Olsen
2024-06-27 11:54:49 +02:00
commit 7db07922aa
69 changed files with 21566 additions and 0 deletions

35
packages/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# 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

1
packages/app/.npmrc Normal file
View File

@@ -0,0 +1 @@
node-linker=hoisted

33
packages/app/app.json Normal file
View File

@@ -0,0 +1,33 @@
{
"expo": {
"name": "react-native-ref",
"slug": "react-native-ref",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"experiments": {
"baseUrl": "/react-native-ref/app"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

8
packages/app/index.js Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import { App } from './src/app';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

View File

@@ -0,0 +1,26 @@
// Learn more https://docs.expo.dev/guides/monorepos
const { getDefaultConfig } = require('expo/metro-config');
const { FileStore } = require('metro-cache');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// #1 - Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// #3 - Force resolving nested modules to the folders below
config.resolver.disableHierarchicalLookup = true;
// #2 - Try resolving with project modules first, then workspace modules
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// Use turborepo to restore the cache when possible
config.cacheStores = [
new FileStore({ root: path.join(projectRoot, 'node_modules', '.cache', 'metro') }),
];
module.exports = config;

32
packages/app/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "react-native-ref",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev:app": "expo start",
"dev:android": "expo start --android",
"dev:ios": "expo start --ios",
"dev:web": "expo start --web",
"build:web": "expo export --platform web"
},
"dependencies": {
"@expo/metro-runtime": "~3.1.3",
"@react-ref/ui": "workspace:^",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
"@react-navigation/stack": "^6.3.29",
"expo": "~50.0.14",
"expo-status-bar": "~1.11.1",
"react": "18.2.0",
"react-native": "0.73.6",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"styled-components": "^6.1.8"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"babel-plugin-module-resolver": "^5.0.2"
},
"private": true
}

View File

@@ -0,0 +1,58 @@
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import * as data from "./api.data";
const useCommunityMessages = () => {
const query = useInfiniteQuery({
initialPageParam: 0,
queryKey: ["communityMessages"],
queryFn: ({ pageParam }) =>
data.getCommunityMessages({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return query;
};
const useLikeCommunityMessage = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (id: string) => data.likeCommunityMessage(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["communityMessages"] });
},
});
return mutation;
};
const useUnlikeCommunityMessage = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (id: string) => data.unlikeCommunityMessage(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["communityMessages"] });
},
});
return mutation;
};
const useAddCommunityMessage = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (content: string) => data.addCommunityMessage(content),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["communityMessages"] });
},
});
return mutation;
};
export {
useCommunityMessages,
useLikeCommunityMessage,
useUnlikeCommunityMessage,
useAddCommunityMessage,
};

View File

@@ -0,0 +1,73 @@
import {
useQuery,
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import * as data from "./api.data";
const useCourse = (id: string) => {
const query = useQuery({
queryKey: ["course", id],
queryFn: () => data.getCourse(id),
});
return query;
};
const useCourses = () => {
const query = useInfiniteQuery({
initialPageParam: 0,
queryKey: ["courses"],
queryFn: ({ pageParam }) => data.getCourses({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return query;
};
const useBookmarkedCourses = () => {
const query = useInfiniteQuery({
initialPageParam: 0,
queryKey: ["bookmarkedCourses"],
queryFn: ({ pageParam }) =>
data.getBookmarkedCourses({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return query;
};
const useAddBookmark = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (id: string) => data.addBookmark(id),
onSuccess: async (_, id) => {
await queryClient.invalidateQueries({ queryKey: ["bookmarkedCourses"] });
await queryClient.invalidateQueries({ queryKey: ["courses"] });
await queryClient.invalidateQueries({ queryKey: ["course", id] });
},
});
return mutation;
};
const useRemoveBookmark = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (id: string) => data.removeBookmark(id),
onSuccess: async (_, id) => {
await queryClient.invalidateQueries({ queryKey: ["bookmarkedCourses"] });
await queryClient.invalidateQueries({ queryKey: ["courses"] });
await queryClient.invalidateQueries({ queryKey: ["course", id] });
},
});
return mutation;
};
export {
useCourse,
useCourses,
useBookmarkedCourses,
useAddBookmark,
useRemoveBookmark,
};

View File

@@ -0,0 +1,138 @@
import type {
CourseThumbProps,
CommunityMessageProps,
} from "@react-ref/ui";
import { addResponseTime } from "./api.utils";
const PAGE_SIZE = 10;
const sortCommunityMessages = (
a: CommunityMessageProps,
b: CommunityMessageProps,
) => b.publishedAt.getDate() - a.publishedAt.getDate();
const communityMessages = new Array(10)
.fill(0)
.map<CommunityMessageProps>((_, i) => ({
id: i.toString(),
user: {
name: `User ${i.toString()}`,
avatar: `https://via.placeholder.com/300?text=User+${i.toString()}`,
},
publishedAt: new Date(Date.now() - (i + 1) * 1000 * 60 * 60 * 24),
content: `Message ${i.toString()}`,
liked: i % 2 === 0,
}))
.sort(sortCommunityMessages);
const courses = new Array(10).fill(0).map<CourseThumbProps>((_, i) => ({
course: {
id: i.toString(),
title: `Course ${i.toString()}`,
cover: `https://via.placeholder.com/300?text=Course+${i.toString()}`,
lessions: 10,
duration: 60,
},
progress:
Math.random() > 0.2 ? { percentage: Math.random() * 100 } : undefined,
bookmarked: Math.random() > 0.5,
}));
const getCourse = addResponseTime((id: string) =>
courses.find((current) => current.course.id === id),
);
interface CoursesRequest {
cursor?: number;
}
const getCourses = addResponseTime(({ cursor = 0 }: CoursesRequest) => {
const items = courses.slice(cursor, cursor + PAGE_SIZE + 1);
const nextCursor =
items.length === PAGE_SIZE + 1 ? cursor + PAGE_SIZE : undefined;
return {
items: items.slice(0, PAGE_SIZE),
nextCursor,
};
});
const getBookmarkedCourses = addResponseTime(
({ cursor = 0 }: CoursesRequest) => {
const items = courses
.filter((course) => course.bookmarked)
.slice(cursor, cursor + PAGE_SIZE + 1);
const nextCursor =
items.length === PAGE_SIZE + 1 ? cursor + PAGE_SIZE : undefined;
return {
items: items.slice(0, PAGE_SIZE),
nextCursor,
};
},
);
const addBookmark = addResponseTime((id: string) => {
const course = courses.find((current) => current.course.id === id);
if (course) {
course.bookmarked = true;
}
});
const removeBookmark = addResponseTime((id: string) => {
const course = courses.find((current) => current.course.id === id);
if (course) {
course.bookmarked = false;
}
});
const getCommunityMessages = addResponseTime(
({ cursor = 0 }: CoursesRequest) => {
const items = communityMessages
.sort(sortCommunityMessages)
.slice(cursor, cursor + PAGE_SIZE + 1);
const nextCursor =
items.length === PAGE_SIZE + 1 ? cursor + PAGE_SIZE : undefined;
return {
items: items.slice(0, PAGE_SIZE),
nextCursor,
};
},
);
const addCommunityMessage = addResponseTime((message: string) => {
const randomId = Math.random().toString();
communityMessages.push({
id: randomId,
user: {
name: "Me",
avatar: "https://via.placeholder.com/300?text=Me",
},
publishedAt: new Date(),
content: message,
liked: false,
});
});
const likeCommunityMessage = addResponseTime((id: string) => {
const message = communityMessages.find((current) => current.id === id);
if (message) {
message.liked = true;
}
});
const unlikeCommunityMessage = addResponseTime((id: string) => {
const message = communityMessages.find((current) => current.id === id);
if (message) {
message.liked = false;
}
});
export {
getCourse,
getCourses,
getBookmarkedCourses,
addBookmark,
removeBookmark,
addCommunityMessage,
getCommunityMessages,
likeCommunityMessage,
unlikeCommunityMessage,
};

View File

@@ -0,0 +1,12 @@
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const addResponseTime = <TArgs extends unknown[], TResponse>(
fn: (...args: TArgs) => TResponse,
) => {
return async (...args: TArgs): Promise<TResponse> => {
await sleep(500);
return fn(...args);
};
};
export { addResponseTime };

33
packages/app/src/app.tsx Normal file
View File

@@ -0,0 +1,33 @@
import "./utils/setup/setup";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider, light } from "@react-ref/ui";
import { Router } from "./router/router";
import styled from "styled-components/native";
import { Platform } from "react-native";
const queryClient = new QueryClient();
const KeyboardAvoidingView = styled.KeyboardAvoidingView`
flex: 1;
`;
const App = () => {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<Provider theme={light}>
<StatusBar style="auto" />
<Router />
</Provider>
</QueryClientProvider>
</SafeAreaProvider>
</KeyboardAvoidingView>
);
};
export { App };

View File

@@ -0,0 +1,80 @@
import { useMemo } from "react";
import { useTheme } from "styled-components/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { NavigationContainer, DefaultTheme } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { Platform } from "react-native";
import type { MainTabParamList, RootStackParamList } from "./types";
import { HomeScreen } from "../screens/home/home";
import { CommunityScreen } from "../screens/community/community";
const MainTabsNvaigator = createBottomTabNavigator<MainTabParamList>();
const MainTabs: React.FC = () => {
const theme = useTheme();
return (
<MainTabsNvaigator.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
}}
>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: "Home",
}}
name="home"
component={HomeScreen}
/>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: "Community",
}}
name="community"
component={CommunityScreen}
/>
</MainTabsNvaigator.Navigator>
);
};
const RootNavigator =
Platform.OS === "web"
? createStackNavigator<RootStackParamList>()
: createNativeStackNavigator<RootStackParamList>();
const Root: React.FC = () => (
<RootNavigator.Navigator
screenOptions={{ headerShown: false, animationEnabled: true }}
>
<RootNavigator.Group>
<RootNavigator.Screen name="main" component={MainTabs} />
</RootNavigator.Group>
</RootNavigator.Navigator>
);
const Router: React.FC = () => {
const theme = useTheme();
const baseTheme = useMemo(() => DefaultTheme, []);
const navigationTheme = useMemo(
() => ({
...baseTheme,
colors: {
...baseTheme.colors,
background: theme.colors.shade,
card: theme.colors.background,
text: theme.colors.text,
},
}),
[baseTheme, theme],
);
return (
<NavigationContainer theme={navigationTheme}>
<Root />
</NavigationContainer>
);
};
export { Router };

View File

@@ -0,0 +1,19 @@
import type {
NavigatorScreenParams,
RouteProp,
} from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type RootStackParamList = {
main: undefined;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type MainTabParamList = {
home: NavigatorScreenParams<RootStackParamList>;
community: NavigatorScreenParams<RootStackParamList>;
};
export type RootRouteProp = RouteProp<RootStackParamList>;
export type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;

View File

@@ -0,0 +1,90 @@
import { useCallback, useState } from "react";
import styled from "styled-components/native";
import { CommunityInput, CommunityMessage } from "@react-ref/ui";
import {
useAddCommunityMessage,
useCommunityMessages,
useLikeCommunityMessage,
useUnlikeCommunityMessage,
} from "../../api/api.community";
import { Keyboard } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Wrapper = styled.View`
flex: 1;
`;
const Container = styled.View`
height: ${({ theme }) => theme.margins.medium}px;
`;
const HeaderElm = styled.View<{ insets: { top: number } }>`
padding-top: ${({ insets }) => insets.top}px;
`;
const Header: React.FC = () => {
const insets = useSafeAreaInsets();
return <HeaderElm insets={insets} />;
};
const List = styled.FlatList`
flex: 1;
padding: ${({ theme }) => theme.margins.medium}px;
`;
const CommunityScreen: React.FC = () => {
const [input, setInput] = useState("");
const messages = useCommunityMessages();
const likeCommuniytMessage = useLikeCommunityMessage();
const unlikeCommuniytMessage = useUnlikeCommunityMessage();
const addCommuniytMessage = useAddCommunityMessage();
const handleLikeChange = useCallback(
(id: string, liked: boolean) => {
if (liked) {
likeCommuniytMessage.mutate(id);
} else {
unlikeCommuniytMessage.mutate(id);
}
},
[likeCommuniytMessage, unlikeCommuniytMessage],
);
const handleSend = useCallback(() => {
addCommuniytMessage.mutate(input, {
onSuccess: () => {
setInput("");
Keyboard.dismiss();
},
});
}, [addCommuniytMessage, input]);
return (
<Wrapper>
<List
data={
messages.data?.pages
.flatMap((page) => page.items)
.sort(
(a, b) => b.publishedAt.getDate() - a.publishedAt.getDate(),
) ?? []
}
keyExtractor={(message) => message.id}
inverted
ItemHeaderComponent={Header}
ItemSeparatorComponent={Container}
renderItem={({ item }) => (
<CommunityMessage
{...item}
onLikeChange={(liked) => {
handleLikeChange(item.id, liked);
}}
/>
)}
/>
<CommunityInput value={input} onChange={setInput} onSend={handleSend} />
</Wrapper>
);
};
export { CommunityScreen };

View File

@@ -0,0 +1,58 @@
import { useCallback } from "react";
import { Carusel, CourseThumb, Title1 } from "@react-ref/ui";
import {
useAddBookmark,
useBookmarkedCourses,
useCourses,
useRemoveBookmark,
} from "../../api/api.courses";
import { SafeAreaView } from "react-native-safe-area-context";
import styled from "styled-components/native";
const Wrapper = styled(SafeAreaView)``;
const HomeScreen: React.FC = () => {
const courses = useCourses();
const bookmarks = useBookmarkedCourses();
const addBookmark = useAddBookmark();
const removeBookmark = useRemoveBookmark();
const onBookmark = useCallback(
(id: string, value: boolean) => {
if (value) {
addBookmark.mutate(id);
} else {
removeBookmark.mutate(id);
}
},
[addBookmark, removeBookmark],
);
return (
<Wrapper>
<Title1>Continue learning</Title1>
<Carusel
items={bookmarks.data?.pages.flatMap((page) => page.items) ?? []}
getKey={(course) => course.course.id}
renderItem={(course) => (
<CourseThumb
{...course}
onBookmarkChange={onBookmark.bind(null, course.course.id)}
/>
)}
/>
<Title1>You may also like</Title1>
<Carusel
items={courses.data?.pages.flatMap((page) => page.items) ?? []}
getKey={(course) => course.course.id}
renderItem={(course) => (
<CourseThumb
{...course}
onBookmarkChange={onBookmark.bind(null, course.course.id)}
/>
)}
/>
</Wrapper>
);
};
export { HomeScreen };

View File

@@ -0,0 +1,9 @@
const NoopScreen: React.FC = () => {
return (
<div>
<h1>Noop Screen</h1>
</div>
);
};
export { NoopScreen };

View File

@@ -0,0 +1 @@
export default "";

View File

@@ -0,0 +1,2 @@
import "@fontsource/montserrat";
export default "";

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"strictNullChecks": true,
},
"extends": "expo/tsconfig.base"
}