mirror of
https://github.com/morten-olsen/react-native-ref.git
synced 2026-02-08 00:36:24 +01:00
init
This commit is contained in:
35
packages/app/.gitignore
vendored
Normal file
35
packages/app/.gitignore
vendored
Normal 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
1
packages/app/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
node-linker=hoisted
|
||||
33
packages/app/app.json
Normal file
33
packages/app/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/app/assets/adaptive-icon.png
Normal file
BIN
packages/app/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
packages/app/assets/favicon.png
Normal file
BIN
packages/app/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/app/assets/icon.png
Normal file
BIN
packages/app/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
packages/app/assets/splash.png
Normal file
BIN
packages/app/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
6
packages/app/babel.config.js
Normal file
6
packages/app/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
8
packages/app/index.js
Normal file
8
packages/app/index.js
Normal 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);
|
||||
26
packages/app/metro.config.js
Normal file
26
packages/app/metro.config.js
Normal 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
32
packages/app/package.json
Normal 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
|
||||
}
|
||||
58
packages/app/src/api/api.community.ts
Normal file
58
packages/app/src/api/api.community.ts
Normal 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,
|
||||
};
|
||||
73
packages/app/src/api/api.courses.ts
Normal file
73
packages/app/src/api/api.courses.ts
Normal 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,
|
||||
};
|
||||
138
packages/app/src/api/api.data.ts
Normal file
138
packages/app/src/api/api.data.ts
Normal 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,
|
||||
};
|
||||
12
packages/app/src/api/api.utils.ts
Normal file
12
packages/app/src/api/api.utils.ts
Normal 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
33
packages/app/src/app.tsx
Normal 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 };
|
||||
80
packages/app/src/router/router.tsx
Normal file
80
packages/app/src/router/router.tsx
Normal 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 };
|
||||
19
packages/app/src/router/types.ts
Normal file
19
packages/app/src/router/types.ts
Normal 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>;
|
||||
90
packages/app/src/screens/community/community.tsx
Normal file
90
packages/app/src/screens/community/community.tsx
Normal 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 };
|
||||
58
packages/app/src/screens/home/home.tsx
Normal file
58
packages/app/src/screens/home/home.tsx
Normal 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 };
|
||||
9
packages/app/src/screens/noop/noop.tsx
Normal file
9
packages/app/src/screens/noop/noop.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const NoopScreen: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Noop Screen</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NoopScreen };
|
||||
1
packages/app/src/utils/setup/setup.ts
Normal file
1
packages/app/src/utils/setup/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default "";
|
||||
2
packages/app/src/utils/setup/setup.web.ts
Normal file
2
packages/app/src/utils/setup/setup.web.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@fontsource/montserrat";
|
||||
export default "";
|
||||
6
packages/app/tsconfig.json
Normal file
6
packages/app/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
1
packages/ui/.gitignore
vendored
Normal file
1
packages/ui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/storybook-static/
|
||||
52
packages/ui/.storybook/main.ts
Normal file
52
packages/ui/.storybook/main.ts
Normal 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;
|
||||
6
packages/ui/.storybook/manager.js
Normal file
6
packages/ui/.storybook/manager.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { addons } from '@storybook/manager-api';
|
||||
import { theme } from './theme';
|
||||
|
||||
addons.setConfig({
|
||||
theme,
|
||||
});
|
||||
40
packages/ui/.storybook/preview.tsx
Normal file
40
packages/ui/.storybook/preview.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
37
packages/ui/.storybook/theme.js
Normal file
37
packages/ui/.storybook/theme.js
Normal 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
55
packages/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
packages/ui/src/components/base/avatar/avatar.tsx
Normal file
31
packages/ui/src/components/base/avatar/avatar.tsx
Normal 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 };
|
||||
4
packages/ui/src/components/base/base.ts
Normal file
4
packages/ui/src/components/base/base.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./avatar/avatar";
|
||||
export * from "./icon/icon";
|
||||
export * from "./relative-time/relative-time";
|
||||
export * from "./carusel/carusel";
|
||||
29
packages/ui/src/components/base/carusel/carusel.stories.tsx
Normal file
29
packages/ui/src/components/base/carusel/carusel.stories.tsx
Normal 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>;
|
||||
38
packages/ui/src/components/base/carusel/carusel.tsx
Normal file
38
packages/ui/src/components/base/carusel/carusel.tsx
Normal 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 };
|
||||
25
packages/ui/src/components/base/icon/icon.native.tsx
Normal file
25
packages/ui/src/components/base/icon/icon.native.tsx
Normal 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 };
|
||||
31
packages/ui/src/components/base/icon/icon.tsx
Normal file
31
packages/ui/src/components/base/icon/icon.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
2
packages/ui/src/components/community/community.ts
Normal file
2
packages/ui/src/components/community/community.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./message/message";
|
||||
export * from "./input/input";
|
||||
64
packages/ui/src/components/community/input/input.stories.tsx
Normal file
64
packages/ui/src/components/community/input/input.stories.tsx
Normal 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>;
|
||||
58
packages/ui/src/components/community/input/input.tsx
Normal file
58
packages/ui/src/components/community/input/input.tsx
Normal 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 };
|
||||
@@ -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>;
|
||||
73
packages/ui/src/components/community/message/message.tsx
Normal file
73
packages/ui/src/components/community/message/message.tsx
Normal 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 };
|
||||
3
packages/ui/src/components/components.ts
Normal file
3
packages/ui/src/components/components.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./base/base";
|
||||
export * from "./community/community";
|
||||
export * from "./course/course";
|
||||
1
packages/ui/src/components/course/course.ts
Normal file
1
packages/ui/src/components/course/course.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./thumb/thumb";
|
||||
46
packages/ui/src/components/course/thumb/thumb.stories.tsx
Normal file
46
packages/ui/src/components/course/thumb/thumb.stories.tsx
Normal 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>;
|
||||
113
packages/ui/src/components/course/thumb/thumb.tsx
Normal file
113
packages/ui/src/components/course/thumb/thumb.tsx
Normal 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 };
|
||||
74
packages/ui/src/foundation /colors.stories.tsx
Normal file
74
packages/ui/src/foundation /colors.stories.tsx
Normal 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 />;
|
||||
};
|
||||
47
packages/ui/src/foundation /icons.stories.tsx
Normal file
47
packages/ui/src/foundation /icons.stories.tsx
Normal 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>;
|
||||
74
packages/ui/src/foundation /spacings.stories.tsx
Normal file
74
packages/ui/src/foundation /spacings.stories.tsx
Normal 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 />;
|
||||
};
|
||||
74
packages/ui/src/foundation /typography.stories.tsx
Normal file
74
packages/ui/src/foundation /typography.stories.tsx
Normal 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
3
packages/ui/src/index.ts
Normal 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
11
packages/ui/src/theme/styled.d.ts
vendored
Normal 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 { }
|
||||
}
|
||||
58
packages/ui/src/theme/theme.light.ts
Normal file
58
packages/ui/src/theme/theme.light.ts
Normal 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 };
|
||||
13
packages/ui/src/theme/theme.provider.tsx
Normal file
13
packages/ui/src/theme/theme.provider.tsx
Normal 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 };
|
||||
3
packages/ui/src/theme/theme.tsx
Normal file
3
packages/ui/src/theme/theme.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./theme.provider";
|
||||
export * from "./theme.types";
|
||||
export * from "./theme.light";
|
||||
47
packages/ui/src/theme/theme.types.ts
Normal file
47
packages/ui/src/theme/theme.types.ts
Normal 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 };
|
||||
57
packages/ui/src/typography/typography.ts
Normal file
57
packages/ui/src/typography/typography.ts
Normal 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
14
packages/ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user