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"
}

1
packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/storybook-static/

View File

@@ -0,0 +1,52 @@
import type { StorybookConfig } from "@storybook/react-webpack5";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-onboarding",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
typescript: {
check: false,
checkOptions: {},
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
compilerOptions: {
allowSyntheticDefaultImports: false,
esModuleInterop: false,
},
}
},
framework: "@storybook/react-webpack5",
docs: {
autodocs: "tag",
},
webpackFinal: (config) => {
config!.resolve!.alias = {
'react-native$': 'react-native-web',
};
config!.module!.rules!.push({
loader: 'babel-loader',
test: /\.tsx?$/,
options: {
presets: ['babel-preset-expo', '@babel/preset-typescript'],
},
});
config!.module!.rules!.push({
loader: 'babel-loader',
test: /\.jsx?$/,
options: {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
},
include: [
/node_modules\/.*react-native.*/,
],
});
return config;
},
};
export default config;

View File

@@ -0,0 +1,6 @@
import { addons } from '@storybook/manager-api';
import { theme } from './theme';
addons.setConfig({
theme,
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Decorator, Parameters } from '@storybook/react';
import "@fontsource/montserrat";
import { ThemeProvider } from 'styled-components';
import { theme } from './theme';
import { Provider, light } from '../src/theme/theme';
const ThemeDecorator: Decorator = (storyFn) => (
<ThemeProvider theme={theme}>
<Provider theme={light}>{storyFn()}</Provider>
</ThemeProvider>
)
export const decorators = [ThemeDecorator];
export const parameters: Parameters = {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: 'dark',
values: [
{
name: 'dark',
value: '#444',
default: true,
},
{
name: 'light',
value: '#ddd',
},
],
},
docs: {
// theme,
},
};

View File

@@ -0,0 +1,37 @@
import { create } from '@storybook/theming';
const theme = create({
base: 'light',
colorPrimary: '#156E80',
colorSecondary: '#156E80',
// UI
appBg: 'white',
appContentBg: '#F7F9FA',
appBorderColor: '#D4DBDE',
appBorderRadius: 4,
// Typography
fontBase: '"Rubik", sans-serif',
fontCode: 'monospace',
// Text colors
textColor: '#003143',
textInverseColor: 'rgba(255,255,255,0.9)',
// Toolbar default and active colors
barTextColor: '#609CA9',
barSelectedColor: '#156E80',
barBg: 'white',
// Form colors
inputBg: 'white',
inputBorder: '#156E80',
inputTextColor: '#003143',
inputBorderRadius: 4,
brandTitle: 'React Native Ref Design System',
});
export { theme };

55
packages/ui/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@react-ref/ui",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"build": "tsc --build",
"build:dev": "tsc --build --watch",
"build:storybook": "storybook build",
"test:storybook": "test-storybook",
"dev:storybook": "storybook dev -p 6006"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/preset-typescript": "^7.24.1",
"@chromatic-com/storybook": "^1.3.3",
"@expo/vector-icons": "^13.0.0",
"@storybook/addon-essentials": "^8.0.9",
"@storybook/addon-interactions": "^8.0.9",
"@storybook/addon-links": "^8.0.9",
"@storybook/addon-onboarding": "^8.0.9",
"@storybook/blocks": "^8.0.9",
"@storybook/manager-api": "^8.0.9",
"@storybook/react": "^8.0.9",
"@storybook/react-webpack5": "^8.0.9",
"@storybook/test": "^8.0.9",
"@storybook/test-runner": "^0.17.0",
"@storybook/theming": "^8.0.9",
"@types/feather-icons": "^4.29.4",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"babel-loader": "^9.1.3",
"babel-preset-expo": "^10.0.2",
"expo": "^50.0.17",
"react": "^18.2.0",
"react-docgen-typescript": "^2.2.2",
"react-dom": "^18.2.0",
"react-native-web": "^0.19.11",
"storybook": "^8.0.9",
"typescript": "^5.4.5"
},
"dependencies": {
"@fontsource/montserrat": "^5.0.18",
"@tanstack/react-query": "^5.32.0",
"date-fns": "^3.6.0",
"feather-icons": "^4.29.1",
"react-native-safe-area-context": "^4.10.1",
"styled-components": "^6.1.8"
}
}

View File

@@ -0,0 +1,31 @@
import styled from "styled-components/native";
import { Title1 } from "../../../typography/typography";
interface AvatarProps {
name: string;
url?: string;
}
const Wrapper = styled.View`
align-items: center;
width: 50px;
height: 50px;
align-items: center;
justify-content: center;
`;
const Image = styled.Image`
width: 50px;
height: 50px;
border-radius: 25px;
`;
const Avatar: React.FC<AvatarProps> = ({ name, url }) => {
return (
<Wrapper>
{url ? <Image source={{ uri: url }} /> : <Title1>{name[0]}</Title1>}
</Wrapper>
);
};
export { Avatar };

View File

@@ -0,0 +1,4 @@
export * from "./avatar/avatar";
export * from "./icon/icon";
export * from "./relative-time/relative-time";
export * from "./carusel/carusel";

View File

@@ -0,0 +1,29 @@
import React from "react";
import { type Meta, type StoryObj } from "@storybook/react";
import { Carusel } from "./carusel";
import { CourseThumb, type CourseThumbProps } from "../../course/thumb/thumb";
type Story = StoryObj<typeof Carusel<CourseThumbProps>>;
const courses = new Array(10).fill(0).map<CourseThumbProps>((_, i) => ({
course: {
id: `${i}`,
title: `Course ${i}`,
cover: `https://via.placeholder.com/300?text=Course+${i}`,
lessions: 10,
duration: 60,
},
}));
export const Message: Story = {
args: {
items: courses,
getKey: (item) => item.course.id,
renderItem: (item) => <CourseThumb course={item.course} />,
},
};
export default {
title: "Components/Base/Carusel",
component: Carusel,
} as Meta<typeof Carusel>;

View File

@@ -0,0 +1,38 @@
import React from "react";
import styled from "styled-components/native";
import type { Theme } from "../../../theme/theme.types";
interface CaruselProps<T> {
items: T[];
getKey: (item: T, index: number) => string;
renderItem: (item: T) => React.ReactNode;
}
const Scroll = styled.ScrollView`
flex-grow: 0;
`;
const Wrapper = styled.View<{ theme: Theme }>`
flex: 1;
flex-direction: row;
justify-content: space-between;
gap: ${({ theme }) => theme.margins.small}px;
padding: ${({ theme }) => theme.margins.small}px;
`;
// eslint-disable-next-line react/function-component-definition
function Carusel<T>({ items, getKey, renderItem }: CaruselProps<T>) {
return (
<Scroll horizontal>
<Wrapper>
{items.map((item, index) => (
<React.Fragment key={getKey(item, index)}>
{renderItem(item)}
</React.Fragment>
))}
</Wrapper>
</Scroll>
);
}
export { Carusel, type CaruselProps };

View File

@@ -0,0 +1,25 @@
import React from "react";
import { Feather } from "@expo/vector-icons";
import { useTheme } from "styled-components/native";
import { type Theme } from "../../../theme/theme";
type IconNames = keyof typeof Feather.glyphMap;
interface IconProps {
size?: number;
color?: keyof Theme["colors"];
name: IconNames;
}
const Icon = ({ size, color, name }: IconProps) => {
const theme = useTheme();
return (
<Feather
name={name}
color={color ? theme.colors[color] : theme.colors.icon}
size={size ?? theme.sizes.icons}
/>
);
};
export type { IconNames };
export { Icon };

View File

@@ -0,0 +1,31 @@
import React from "react";
import { icons } from "feather-icons";
import { useTheme } from "styled-components/native";
import { type Theme } from "../../../theme/theme";
type IconNames = keyof typeof icons;
interface IconProps {
size?: number;
color?: keyof Theme["colors"];
name: IconNames;
}
const Icon = ({ size = 24, color, name }: IconProps) => {
const theme = useTheme();
return (
<svg
dangerouslySetInnerHTML={{
__html: icons[name].toSvg({
color: color ? theme.colors[color] : undefined,
}),
}}
viewBox="0 0 24 24"
width={size}
height={size}
fill={color ? theme.colors[color] : undefined}
/>
);
};
export type { IconNames };
export { Icon };

View File

@@ -0,0 +1,26 @@
import { formatDistanceToNow } from "date-fns";
import { useEffect, useState } from "react";
interface RelativeTimeProps {
date: Date;
}
const format = (date: Date) => formatDistanceToNow(date, { addSuffix: true });
const RelativeTime: React.FC<RelativeTimeProps> = ({ date }) => {
const [displayDate, setDisplayDate] = useState(format(date));
useEffect(() => {
const interval = setInterval(() => {
setDisplayDate(format(date));
}, 1000 * 30);
setDisplayDate(format(date));
return () => {
clearInterval(interval);
};
}, [date]);
return <>{displayDate}</>;
};
export { RelativeTime };

View File

@@ -0,0 +1,2 @@
export * from "./message/message";
export * from "./input/input";

View File

@@ -0,0 +1,64 @@
import React, { useCallback, useState } from "react";
import { type Meta, type StoryObj } from "@storybook/react";
import { userEvent, within, expect } from '@storybook/test';
import { CommunityInput } from "./input";
import { Body1 } from "../../../typography/typography";
type Story = StoryObj<typeof CommunityInput>;
export const Empty: Story = {
args: {},
};
export const WithText: Story = {
args: {
value: "Hello, world!",
},
};
export const Interaction: Story = {
render: () => {
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState("");
const onSend = useCallback(() => {
setSubmitted(value);
setValue('');
}, [value]);
return (
<>
<div style={{ display: 'none' }}>
<Body1 testID="submitted-text">{submitted}</Body1>
</div>
<CommunityInput
value={value}
onChange={(e) => setValue(e)}
onSend={onSend}
/>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const submitted = canvas.getByTestId('submitted-text');
const textField = canvas.getByTestId('community-input-text');
const button = canvas.getByTestId('community-input-button');
expect(submitted).toHaveTextContent('');
expect(textField).toHaveValue('');
await userEvent.type(textField, 'Hey there!');
expect(submitted).toHaveTextContent('');
expect(textField).toHaveValue('Hey there!');
await userEvent.click(button);
expect(submitted).toHaveTextContent('Hey there!');
expect(textField).toHaveValue('');
}
};
export default {
title: "Components/Community/Input",
component: CommunityInput,
} as Meta<typeof CommunityInput>;

View File

@@ -0,0 +1,58 @@
import styled from "styled-components/native";
import { Body1 } from "../../../typography/typography";
import { Icon } from "../../base/icon/icon";
interface CommunityInputProps {
sending?: boolean;
value: string;
onChange?: (value: string) => void;
onSend?: () => void;
}
const Wrapper = styled.View`
overflow: hidden;
border-radius: ${({ theme }) => theme.sizes.corners}px;
background-color: ${({ theme }) => theme.colors.background};
padding: ${({ theme }) => theme.margins.small}px;
gap: ${({ theme }) => theme.margins.medium}px;
flex-direction: row;
`;
const InputField = styled.TextInput`
flex: 1;
color: ${({ theme }) => theme.colors.text};
`;
const Send = styled.TouchableOpacity`
flex-direction: row;
align-items: center;
gap: ${({ theme }) => theme.margins.small}px;
background: ${({ theme }) => theme.colors.primary};
padding: ${({ theme }) => theme.margins.small}px;
border-radius: ${({ theme }) => theme.sizes.corners}px;
`;
const CommunityInput: React.FC<CommunityInputProps> = ({
value,
onChange,
onSend,
}) => {
return (
<Wrapper>
<InputField
placeholder="Join the conversation"
value={value}
testID="community-input-text"
onChangeText={onChange}
/>
<Send onPress={onSend} testID="community-input-button">
<>
<Icon color="primaryContrast" name="send" />
<Body1 color="primaryContrast">Send</Body1>
</>
</Send>
</Wrapper>
);
};
export { CommunityInput, type CommunityInputProps };

View File

@@ -0,0 +1,21 @@
import { type Meta, type StoryObj } from "@storybook/react";
import { CommunityMessage } from "./message";
type Story = StoryObj<typeof CommunityMessage>;
export const Message: Story = {
args: {
user: {
name: "User name",
avatar: "https://via.placeholder.com/300",
},
content: "Message content",
publishedAt: new Date("2021-01-01T00:00:00"),
liked: false,
},
};
export default {
title: "Components/Community/Message",
component: CommunityMessage,
} as Meta<typeof CommunityMessage>;

View File

@@ -0,0 +1,73 @@
import styled from "styled-components/native";
import { Body1, Caption } from "../../../typography/typography";
import { Icon } from "../../base/icon/icon";
import { TouchableOpacity } from "react-native";
import { RelativeTime } from "../../base/relative-time/relative-time";
import { Avatar } from "../../base/avatar/avatar";
interface CommunityMessageProps {
id: string;
user: {
name: string;
avatar: string;
};
publishedAt: Date;
content: string;
liked: boolean;
onLikeChange?: (liked: boolean) => void;
}
const Wrapper = styled.View`
overflow: hidden;
border-radius: ${({ theme }) => theme.sizes.corners}px;
background-color: ${({ theme }) => theme.colors.background};
padding: ${({ theme }) => theme.margins.medium}px;
gap: ${({ theme }) => theme.margins.medium}px;
`;
const Header = styled.View`
flex-direction: row;
gap: ${({ theme }) => theme.margins.medium}px;
align-items: center;
`;
const HeaderInfo = styled.View``;
const Actions = styled.View`
flex-direction: row;
gap: ${({ theme }) => theme.margins.small}px;
`;
const CommunityMessage: React.FC<CommunityMessageProps> = ({
user,
publishedAt,
content,
liked,
onLikeChange,
}) => {
return (
<Wrapper>
<Header>
<Avatar url={user.avatar} name={user.name} />
<HeaderInfo>
<Body1>{user.name}</Body1>
<Caption color="textShade">
<RelativeTime date={publishedAt} />
</Caption>
</HeaderInfo>
</Header>
<Body1>{content}</Body1>
<Actions>
<TouchableOpacity
onPress={() => {
onLikeChange && onLikeChange(!liked);
}}
>
<Icon name={liked ? "heart" : "airplay"} size={16} />
</TouchableOpacity>
</Actions>
</Wrapper>
);
};
export { CommunityMessage, type CommunityMessageProps };

View File

@@ -0,0 +1,3 @@
export * from "./base/base";
export * from "./community/community";
export * from "./course/course";

View File

@@ -0,0 +1 @@
export * from "./thumb/thumb";

View File

@@ -0,0 +1,46 @@
import { type Meta, type StoryObj } from "@storybook/react";
import { CourseThumb } from "./thumb";
type Story = StoryObj<typeof CourseThumb>;
export const WithProgress: Story = {
args: {
course: {
title: "Course title",
cover: "https://via.placeholder.com/300",
lessions: 10,
duration: 60,
},
progress: {
percentage: 27,
},
},
};
export const WithoutProgress: Story = {
args: {
course: {
title: "Course title",
cover: "https://via.placeholder.com/300",
lessions: 10,
duration: 60,
},
},
};
export const Bookmarked: Story = {
args: {
course: {
title: "Course title",
cover: "https://via.placeholder.com/300",
lessions: 10,
duration: 60,
},
bookmarked: true,
},
};
export default {
title: "Components/Course/Thumb",
component: CourseThumb,
} as Meta<typeof CourseThumb>;

View File

@@ -0,0 +1,113 @@
import styled from "styled-components/native";
import { Caption, Title1 } from "../../../typography/typography";
import { Icon } from "../../base/icon/icon";
import { TouchableOpacity } from "react-native";
interface CourseThumbProps {
onPress?: () => void;
onBookmarkChange?: (state: boolean) => void;
course: {
id: string;
title: string;
cover: string;
lessions: number;
duration: number;
};
bookmarked?: boolean;
progress?: {
percentage: number;
};
}
const Wrapper = styled.View`
overflow: hidden;
width: 300px;
border-radius: ${({ theme }) => theme.sizes.corners}px;
background-color: ${({ theme }) => theme.colors.background};
position: relative;
`;
const Cover = styled.Image`
width: 100%;
aspect-ratio: 16/9;
border-top-left-radius: ${({ theme }) => theme.sizes.corners}px;
border-top-right-radius: ${({ theme }) => theme.sizes.corners}px;
`;
const Info = styled.View`
padding: ${({ theme }) => theme.margins.small}px;
gap: ${({ theme }) => theme.margins.small}px;
`;
const Meta = styled.View`
flex-direction: row;
gap: ${({ theme }) => theme.margins.small}px;
`;
const MetaItem = styled.View`
flex-direction: row;
align-items: center;
gap: ${({ theme }) => theme.margins.small}px;
`;
const ProgressWrapper = styled.View`
background-color: ${({ theme }) => theme.colors.primary};
position: absolute;
top: ${({ theme }) => theme.margins.small}px;
left: ${({ theme }) => theme.margins.small}px;
padding: ${({ theme }) => theme.margins.small}px;
border-radius: ${({ theme }) => theme.sizes.corners}px;
`;
const BookmarkWrapper = styled.View`
position: absolute;
top: ${({ theme }) => theme.margins.small}px;
right: ${({ theme }) => theme.margins.small}px;
padding: ${({ theme }) => theme.margins.small}px;
`;
const CourseThumb: React.FC<CourseThumbProps> = ({
course,
progress,
bookmarked,
onPress,
onBookmarkChange,
}) => {
return (
<TouchableOpacity onPress={onPress}>
<Wrapper>
<Cover source={{ uri: course.cover }} />
{progress ? (
<ProgressWrapper>
<Caption color="primaryContrast">
{Math.round(progress.percentage)}% completed
</Caption>
</ProgressWrapper>
) : null}
<BookmarkWrapper>
<TouchableOpacity
onPress={() => {
onBookmarkChange && onBookmarkChange(!bookmarked);
}}
>
<Icon name={bookmarked ? "book-open" : "book"} size={24} />
</TouchableOpacity>
</BookmarkWrapper>
<Info>
<Title1>{course.title}</Title1>
<Meta>
<MetaItem>
<Caption>{course.lessions} lessons</Caption>
</MetaItem>
<MetaItem>
<Icon name="clock" size={16} />
<Caption>{course.duration} min</Caption>
</MetaItem>
</Meta>
</Info>
</Wrapper>
</TouchableOpacity>
);
};
export { CourseThumb, type CourseThumbProps };

View File

@@ -0,0 +1,74 @@
import React from "react";
import { type Meta } from "@storybook/react";
import { useTheme } from "styled-components/native";
import styled from "styled-components";
const Table = styled.table`
background: white;
border-radius: 5px;
width: 100%;
max-width: 900px;
margin: auto;
border-collapse: collapse;
border-spacing: 0;
td {
margin: 0;
padding: 15px 15px;
}
`;
const Thead = styled.thead`
font-weight: bold;
`;
const Row = styled.tr`
padding: 0 15px;
&:nth-child(even) {
background: rgba(0, 0, 0, 0.05);
}
`;
const Example = styled.div<{ color: string }>`
background: ${(props) => props.color};
width: 50px;
height: 50px;
`;
const SpacingComponent = () => {
const theme = useTheme();
return (
<Table>
<Thead>
<tr>
<td>Example</td>
<td>Name</td>
<td>Color</td>
</tr>
</Thead>
<tbody>
{Object.entries(theme.colors).map(([key, value]) => {
return (
<Row key={key}>
<td>
<Example color={value} />
</td>
<td>{key}</td>
<td>{value}</td>
</Row>
);
})}
</tbody>
</Table>
);
};
export default {
title: "Foundation/Colors",
component: SpacingComponent,
} as Meta<typeof SpacingComponent>;
export const Colors = () => {
return <SpacingComponent />;
};

View File

@@ -0,0 +1,47 @@
import React from "react";
import { type Meta } from "@storybook/react";
import styled from "styled-components";
import { Icon } from "../components/base/icon/icon";
import { icons } from "feather-icons";
const Table = styled.div`
background: white;
border-radius: 5px;
width: 100%;
max-width: 900px;
margin: auto;
display: flex;
flex-wrap: wrap;
`;
const Row = styled.div`
padding: 35px;
&:nth-child(even) {
background: rgba(0, 0, 0, 0.05);
}
`;
const Name = styled.div`
text-align: center;
font-weight: bold;
`;
export const Icons = () => {
return (
<Table>
{Object.keys(icons).map((key) => {
return (
<Row key={key}>
<Icon name={key as keyof typeof icons} size={120} />
<Name>{key}</Name>
</Row>
);
})}
</Table>
);
};
export default {
title: "Foundation/Icons",
component: Icons,
} as Meta<typeof Icons>;

View File

@@ -0,0 +1,74 @@
import React from "react";
import { type Meta } from "@storybook/react";
import { useTheme } from "styled-components/native";
import styled from "styled-components";
const Table = styled.table`
background: white;
border-radius: 5px;
width: 100%;
max-width: 900px;
margin: auto;
border-collapse: collapse;
border-spacing: 0;
td {
margin: 0;
padding: 15px 15px;
}
`;
const Thead = styled.thead`
font-weight: bold;
`;
const Row = styled.tr`
padding: 0 15px;
&:nth-child(even) {
background: rgba(0, 0, 0, 0.05);
}
`;
const Example = styled.div<{ size: number }>`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
background: red;
`;
const SpacingComponent = () => {
const theme = useTheme();
return (
<Table>
<Thead>
<tr>
<td>Example</td>
<td>Name</td>
<td>Size</td>
</tr>
</Thead>
<tbody>
{Object.entries(theme.margins).map(([key, value]) => {
return (
<Row key={key}>
<td>
<Example size={value} />
</td>
<td>{key}</td>
<td>{value}px</td>
</Row>
);
})}
</tbody>
</Table>
);
};
export default {
title: "Foundation/Spacing",
component: SpacingComponent,
} as Meta<typeof SpacingComponent>;
export const Spacing = () => {
return <SpacingComponent />;
};

View File

@@ -0,0 +1,74 @@
import React from "react";
import { type Meta } from "@storybook/react";
import { useTheme } from "styled-components/native";
import { types } from "../typography/typography";
import styled from "styled-components";
const Table = styled.table`
background: white;
border-radius: 5px;
width: 100%;
max-width: 900px;
margin: auto;
border-collapse: collapse;
border-spacing: 0;
td {
margin: 0;
padding: 15px 15px;
}
`;
const Thead = styled.thead`
font-weight: bold;
`;
const Row = styled.tr`
padding: 0 15px;
&:nth-child(even) {
background: rgba(0, 0, 0, 0.05);
}
`;
const TypographyComponent = () => {
const theme = useTheme();
return (
<Table>
<Thead>
<tr>
<td>Example</td>
<td>Name</td>
<td>Size</td>
<td>Weight</td>
<td>Spacing</td>
</tr>
</Thead>
<tbody>
{Object.entries(theme.typography).map(([key, value]) => {
const Component = types[key as keyof typeof types];
return (
<Row key={key}>
<td>
<Component>{key}</Component>
</td>
<td>{key}</td>
<td>{value.size ?? 1}x</td>
<td>{value.weight ?? "normal"}</td>
<td>{value.spacing ?? 0}px</td>
</Row>
);
})}
</tbody>
</Table>
);
};
export default {
title: "Foundation/Typography",
component: TypographyComponent,
} as Meta<typeof TypographyComponent>;
export const Typography = () => {
return <TypographyComponent />;
};

3
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./components/components";
export * from "./typography/typography";
export * from "./theme/theme";

11
packages/ui/src/theme/styled.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import { } from "styled-components/native";
import { type Theme } from "./theme.types"; // Import type from above file
declare module "styled-components" {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DefaultTheme extends Theme { }
}
declare module "styled-components/native" {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DefaultTheme extends Theme { }
}

View File

@@ -0,0 +1,58 @@
import { Platform } from "react-native";
import { type Theme } from "./theme";
const light: Theme = {
typography: {
Jumbo: {
weight: "bold",
size: 2.8,
},
Title1: {
weight: "bold",
},
Title2: {
weight: "bold",
size: 1.3,
},
Body1: {},
Overline: {
size: 0.8,
upperCase: true,
},
Caption: {
size: 0.8,
},
Link: {
upperCase: true,
weight: "bold",
},
},
colors: {
primary: "#156e80",
primaryContrast: "#fff",
icon: "#156e80",
destructive: "#e74c3c",
shade: "#ededed",
input: "#ddd",
secondary: "#ff9f43",
shadow: "#000",
background: "#fff",
text: "#000",
textShade: "#999",
},
sizes: {
corners: 5,
icons: 24,
},
margins: {
small: 8,
medium: 16,
large: 24,
},
font: {
family: Platform.OS === "web" ? "Montserrat" : undefined,
baseSize: Platform.OS === "web" ? 14 : 11,
},
};
export { light };

View File

@@ -0,0 +1,13 @@
import { ThemeProvider } from "styled-components/native";
import { type Theme } from "./theme.types";
interface ProviderProps {
children: React.ReactNode;
theme: Theme;
}
const Provider: React.FC<ProviderProps> = ({ children, theme }) => (
<ThemeProvider theme={theme}>{children}</ThemeProvider>
);
export { Provider };

View File

@@ -0,0 +1,3 @@
export * from "./theme.provider";
export * from "./theme.types";
export * from "./theme.light";

View File

@@ -0,0 +1,47 @@
interface Typography {
family?: string;
size?: number;
spacing?: number;
weight?: string;
upperCase?: boolean;
}
interface Theme {
typography: {
Jumbo: Typography;
Title2: Typography;
Title1: Typography;
Body1: Typography;
Caption: Typography;
Overline: Typography;
Link: Typography;
};
colors: {
primary: string;
primaryContrast: string;
destructive: string;
icon: string;
input: string;
secondary: string;
background: string;
shadow: string;
shade: string;
text: string;
textShade: string;
};
sizes: {
corners: number;
icons: number;
};
margins: {
small: number;
medium: number;
large: number;
};
font: {
family?: string;
baseSize: number;
};
}
export type { Theme, Typography };

View File

@@ -0,0 +1,57 @@
import styled from "styled-components/native";
import type { Theme, Typography } from "../theme/theme";
interface TextProps {
color?: keyof Theme["colors"];
bold?: boolean;
theme: Theme;
}
const BaseText = styled.Text<TextProps>`
${({ theme }) =>
theme.font.family ? `font-family: ${theme.font.family};` : ""}
color: ${({ color, theme }) =>
color ? theme.colors[color] : theme.colors.text};
font-weight: ${({ bold }) => (bold ? "bold" : "normal")};
font-size: ${({ theme }) => theme.font.baseSize}px;
`;
const get = (name: keyof Theme["typography"], theme: Theme): Typography => {
const typography = theme.typography[name];
return typography;
};
const createTypography = (name: keyof Theme["typography"]) => {
const Component = styled(BaseText)<TextProps>`
font-size: ${({ theme }) =>
theme.font.baseSize * (get(name, theme).size ?? 1)}px;
font-weight: ${({ bold, theme }) =>
typeof bold !== "undefined"
? "bold"
: get(name, theme).weight ?? "normal"};
${({ theme }) =>
get(name, theme).upperCase ? "text-transform: uppercase;" : ""}
`;
return Component;
};
const Jumbo = createTypography("Jumbo");
const Title2 = createTypography("Title2");
const Title1 = createTypography("Title1");
const Body1 = createTypography("Body1");
const Overline = createTypography("Overline");
const Caption = createTypography("Caption");
const Link = createTypography("Link");
const types: { [key in keyof Theme["typography"]]: typeof BaseText } = {
Jumbo,
Title2,
Title1,
Body1,
Overline,
Caption,
Link,
};
export type { TextProps };
export { types, Jumbo, Title2, Title1, Body1, Overline, Caption, Link };

14
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"noEmit": false,
"declaration": true,
"sourceMap": true,
"declarationMap": true
},
"include": ["src"],
"exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx", "**/*.stories.ts", "node_modules", "dist"]
}