chore: initial commit

This commit is contained in:
Khairul Hidayat 2024-03-09 18:48:49 +07:00
commit 01e5ae1a19
27 changed files with 8511 additions and 0 deletions

35
.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

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
shamefully-hoist=true
node-linker=hoisted

12
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"tailwindCSS.classAttributes": [
"style",
"className",
],
"tailwindCSS.experimental.classRegex": [
"tw`([^`]*)",
["tw.style\\(([^)]*)\\)", "'([^']*)'"],
"cn\\(([^)]*)\\)",
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
]
}

30
app.json Normal file
View File

@ -0,0 +1,30 @@
{
"expo": {
"name": "myapp",
"slug": "myapp",
"scheme": "myapp",
"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"
},
"plugins": ["expo-router"]
}
}

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

18
babel.config.js Normal file
View File

@ -0,0 +1,18 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
root: ['./src'],
alias: {
'@/': './src',
'@ui': './src/components/ui',
},
},
],
],
};
};

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "myapp",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~3.1.3",
"@types/react": "~18.2.45",
"class-variance-authority": "^0.7.0",
"expo": "~50.0.11",
"expo-constants": "~15.4.5",
"expo-linking": "~6.2.2",
"expo-router": "~3.4.8",
"expo-status-bar": "~1.11.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.4",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"react-native-web": "~0.19.6",
"react-query": "^3.39.3",
"twrnc": "^4.1.0",
"typescript": "^5.3.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"babel-plugin-module-resolver": "^5.0.0"
},
"private": true
}

7814
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
src/app/_layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from "react";
import { Slot } from "expo-router";
import { QueryClientProvider } from "react-query";
import queryClient from "@/lib/queryClient";
import { View } from "react-native";
import { cn } from "@/lib/utils";
import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const RootLayout = () => {
const insets = useSafeAreaInsets();
return (
<QueryClientProvider client={queryClient}>
<StatusBar style="auto" />
<View style={cn("flex-1 bg-white", { paddingTop: insets.top })}>
<Slot />
</View>
</QueryClientProvider>
);
};
export default RootLayout;

27
src/app/index.tsx Normal file
View File

@ -0,0 +1,27 @@
import { Text } from "react-native";
import React from "react";
import { cn } from "@/lib/utils";
import useAPI from "@/hooks/useAPI";
import { Ionicons } from "@ui/Icons";
import { VStack } from "@ui/Stack";
import Button from "@ui/Button";
const App = () => {
const { data } = useAPI("/posts/1");
return (
<VStack className="gap-3 p-4">
<Text style={cn("text-2xl font-medium")}>App</Text>
<Text style={cn("w-full")}>{data?.body}</Text>
<Button label="Click me" />
<Button label="Click me" variant="secondary" />
<Button label="Click me" variant="ghost" />
<Button label="Click me" variant="outline" />
<Button label="Click me" variant="destructive" />
<Button icon={<Ionicons name="trash" />} size="icon" />
</VStack>
);
};
export default App;

11
src/components/ui/Box.tsx Normal file
View File

@ -0,0 +1,11 @@
import { cn } from "@/lib/utils";
import { ComponentPropsWithClassName } from "@/types/components";
import { View } from "react-native";
type Props = ComponentPropsWithClassName<typeof View>;
const Box = ({ className, ...props }: Props) => {
return <View style={cn(className)} {...props} />;
};
export default Box;

View File

@ -0,0 +1,96 @@
import { cn } from "@/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import { Pressable, Text } from "react-native";
import React from "react";
import Slot from "./Slot";
const buttonVariants = cva(
"flex flex-row items-center justify-center rounded-md",
{
variants: {
variant: {
default: "bg-primary",
secondary: "bg-secondary",
destructive: "bg-red-500",
ghost: "",
link: "text-primary underline-offset-4",
outline: "border border-primary",
},
size: {
default: "h-10 px-4",
sm: "h-8 px-2",
lg: "h-12 px-8",
icon: "h-10 w-10 px-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const buttonTextVariants = cva("text-center font-medium", {
variants: {
variant: {
default: "text-primary-foreground",
secondary: "text-secondary-foreground",
destructive: "text-white",
ghost: "text-primary",
link: "text-primary-foreground underline",
outline: "text-primary",
},
size: {
default: "text-base",
sm: "text-sm",
lg: "text-xl",
icon: "text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
interface ButtonProps
extends React.ComponentPropsWithoutRef<typeof Pressable>,
VariantProps<typeof buttonVariants> {
label?: string;
labelClasses?: string;
className?: string;
icon?: React.ReactNode;
}
function Button({
label,
labelClasses,
className,
variant,
size,
icon,
...props
}: ButtonProps) {
const textStyles = cn(
buttonTextVariants({ variant, size, className: labelClasses })
);
return (
<Pressable
style={({ pressed }) =>
cn(
buttonVariants({ variant, size, className }),
pressed ? "opacity-60" : "opacity-100"
)
}
{...props}
>
{icon ? <Slot.View style={textStyles}>{icon}</Slot.View> : null}
{label ? <Text style={textStyles}>{label}</Text> : null}
</Pressable>
);
}
export { buttonVariants, buttonTextVariants };
export default Button;

View File

@ -0,0 +1,3 @@
import Ionicons from "@expo/vector-icons/Ionicons";
export { Ionicons };

206
src/components/ui/Slot.tsx Normal file
View File

@ -0,0 +1,206 @@
import * as React from "react";
import {
Image as RNImage,
Pressable as RNPressable,
Text as RNText,
View as RNView,
StyleSheet,
type PressableStateCallbackType,
type ImageProps as RNImageProps,
type ImageStyle as RNImageStyle,
type PressableProps as RNPressableprops,
type TextProps as RNTextProps,
type ViewProps as RNViewProps,
type StyleProp,
} from "react-native";
const Pressable = React.forwardRef<
React.ElementRef<typeof RNPressable>,
RNPressableprops
>((props, forwardedRef) => {
const { children, ...pressableslotProps } = props;
if (!React.isValidElement(children)) {
console.log("Slot.Pressable - Invalid asChild element", children);
return null;
}
return React.cloneElement<
React.ComponentPropsWithoutRef<typeof RNPressable>,
React.ElementRef<typeof RNPressable>
>(isTextChildren(children) ? <></> : children, {
...mergeProps(pressableslotProps, children.props),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
});
});
Pressable.displayName = "SlotPressable";
const View = React.forwardRef<React.ElementRef<typeof RNView>, RNViewProps>(
(props, forwardedRef) => {
const { children, ...viewSlotProps } = props;
if (!React.isValidElement(children)) {
console.log("Slot.View - Invalid asChild element", children);
return null;
}
return React.cloneElement<
React.ComponentPropsWithoutRef<typeof RNView>,
React.ElementRef<typeof RNView>
>(isTextChildren(children) ? <></> : children, {
...mergeProps(viewSlotProps, children.props),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
});
}
);
View.displayName = "SlotView";
const Text = React.forwardRef<React.ElementRef<typeof RNText>, RNTextProps>(
(props, forwardedRef) => {
const { children, ...textSlotProps } = props;
if (!React.isValidElement(children)) {
console.log("Slot.Text - Invalid asChild element", children);
return null;
}
return React.cloneElement<
React.ComponentPropsWithoutRef<typeof RNText>,
React.ElementRef<typeof RNText>
>(isTextChildren(children) ? <></> : children, {
...mergeProps(textSlotProps, children.props),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
});
}
);
Text.displayName = "SlotText";
type ImageSlotProps = RNImageProps & {
children?: React.ReactNode;
};
const Image = React.forwardRef<
React.ElementRef<typeof RNImage>,
ImageSlotProps
>((props, forwardedRef) => {
const { children, ...imageSlotProps } = props;
if (!React.isValidElement(children)) {
console.log("Slot.Image - Invalid asChild element", children);
return null;
}
return React.cloneElement<
React.ComponentPropsWithoutRef<typeof RNImage>,
React.ElementRef<typeof RNImage>
>(isTextChildren(children) ? <></> : children, {
...mergeProps(imageSlotProps, children.props),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
});
});
Image.displayName = "SlotImage";
const Slot = { Image, Pressable, Text, View };
export default Slot;
// This project uses code from WorkOS/Radix Primitives.
// The code is licensed under the MIT License.
// https://github.com/radix-ui/primitives/tree/main
function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T) =>
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(node);
} else if (ref != null) {
(ref as React.MutableRefObject<T>).current = node;
}
});
}
type AnyProps = Record<string, any>;
function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
// all child props should override
const overrideProps = { ...childProps };
for (const propName in childProps) {
const slotPropValue = slotProps[propName];
const childPropValue = childProps[propName];
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler) {
// if the handler exists on both, we compose them
if (slotPropValue && childPropValue) {
overrideProps[propName] = (...args: unknown[]) => {
childPropValue(...args);
slotPropValue(...args);
};
}
// but if it exists only on the slot, we use only this one
else if (slotPropValue) {
overrideProps[propName] = slotPropValue;
}
}
// if it's `style`, we merge them
else if (propName === "style") {
overrideProps[propName] = combineStyles(slotPropValue, childPropValue);
} else if (propName === "className") {
overrideProps[propName] = [slotPropValue, childPropValue]
.filter(Boolean)
.join(" ");
}
}
return { ...slotProps, ...overrideProps };
}
type PressableStyle = RNPressableprops["style"];
type ImageStyle = StyleProp<RNImageStyle>;
type Style = PressableStyle | ImageStyle;
function combineStyles(slotStyle?: Style, childValue?: Style) {
if (typeof slotStyle === "function" && typeof childValue === "function") {
return (state: PressableStateCallbackType) => {
return StyleSheet.flatten([slotStyle(state), childValue(state)]);
};
}
if (typeof slotStyle === "function") {
return (state: PressableStateCallbackType) => {
return childValue
? StyleSheet.flatten([slotStyle(state), childValue])
: slotStyle(state);
};
}
if (typeof childValue === "function") {
return (state: PressableStateCallbackType) => {
return slotStyle
? StyleSheet.flatten([slotStyle, childValue(state)])
: childValue(state);
};
}
return StyleSheet.flatten([slotStyle, childValue].filter(Boolean));
}
export function isTextChildren(
children:
| React.ReactNode
| ((state: PressableStateCallbackType) => React.ReactNode)
) {
return Array.isArray(children)
? children.every((child) => typeof child === "string")
: typeof children === "string";
}

View File

@ -0,0 +1,28 @@
import React from "react";
import Box from "./Box";
import { ComponentPropsWithClassName } from "@/types/components";
type StackProps = ComponentPropsWithClassName<typeof Box> & {
direction?: "row" | "column";
};
const Stack = ({ direction = "row", className, ...props }: StackProps) => {
return (
<Box
className={[
"flex",
direction === "row"
? "flex-row items-center"
: "flex-col items-stretch",
className,
]}
{...props}
/>
);
};
const HStack = (props: StackProps) => <Stack direction="row" {...props} />;
const VStack = (props: StackProps) => <Stack direction="column" {...props} />;
export { HStack, VStack };
export default Stack;

View File

@ -0,0 +1,5 @@
import Box from "./Box";
import Button from "./Button";
import Stack, { HStack, VStack } from "./Stack";
export { Box, Button, Stack, HStack, VStack };

11
src/hooks/useAPI.ts Normal file
View File

@ -0,0 +1,11 @@
import api, { APIParams } from "@/lib/api";
import { useQuery } from "react-query";
const useAPI = <T = any>(url: string, params?: APIParams) => {
return useQuery({
queryKey: [url, params],
queryFn: async () => api<T>(url, { params }),
});
};
export default useAPI;

75
src/lib/api.ts Normal file
View File

@ -0,0 +1,75 @@
import { API_BASEURL } from "./constants";
export type APIParams = { [key: string]: any };
export type APIOptions = RequestInit & {
params?: APIParams;
};
const api = async <T>(url: string, options: APIOptions = {}): Promise<T> => {
const fullUrl = new URL(url, API_BASEURL);
if (options.params && Object.keys(options.params).length > 0) {
for (const [key, value] of Object.entries(options.params)) {
fullUrl.searchParams.append(key, value);
}
}
const response = await fetch(fullUrl.toString(), options);
if (!response.ok) {
throw new APIError(response);
}
if (response.headers.get("content-type")?.includes("application/json")) {
const data = (await response.json()) as T;
return data;
}
const data = await response.text();
return data as T;
};
api.post = async <T>(url: string, body: any, options: APIOptions = {}) => {
return api<T>(url, {
method: "POST",
body: JSON.stringify(body),
...options,
});
};
api.patch = async <T>(url: string, body: any, options: APIOptions = {}) => {
return api<T>(url, {
method: "PATCH",
body: JSON.stringify(body),
...options,
});
};
api.put = async <T>(url: string, body: any, options: APIOptions = {}) => {
return api<T>(url, {
method: "PUT",
body: JSON.stringify(body),
...options,
});
};
api.delete = async <T>(url: string, options: APIOptions = {}) => {
return api<T>(url, {
method: "DELETE",
...options,
});
};
export class APIError extends Error {
res: Response;
code: number;
constructor(res: Response) {
super(res.statusText);
this.res = res;
this.code = res.status;
}
}
export default api;

2
src/lib/constants.ts Normal file
View File

@ -0,0 +1,2 @@
// export const API_BASEURL = "http://localhost:8000";
export const API_BASEURL = "https://jsonplaceholder.typicode.com";

11
src/lib/queryClient.ts Normal file
View File

@ -0,0 +1,11 @@
import { QueryClient } from "react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export default queryClient;

11
src/lib/utils.ts Normal file
View File

@ -0,0 +1,11 @@
import { ClassInput, create as createTwrnc } from "twrnc";
const tw = createTwrnc(require(`../../tailwind.config.js`));
export const cn = (...args: ClassInput[]) => {
if (Array.isArray(args[0])) {
return tw.style(...args[0]);
}
return tw.style(...args);
};

6
src/types/components.ts Normal file
View File

@ -0,0 +1,6 @@
import { ComponentProps } from "react";
export type ComponentPropsWithClassName<T extends React.ElementType> =
ComponentProps<T> & {
className?: any;
};

40
tailwind.config.js Normal file
View File

@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{jsx,tsx}"],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#6366F1",
50: "#FFFFFF",
100: "#F9F9FE",
200: "#D3D4FB",
300: "#AEAFF8",
400: "#888BF4",
500: "#6366F1",
600: "#3034EC",
700: "#1317D1",
800: "#0E119E",
900: "#0A0C6A",
950: "#070950",
},
'primary-foreground': '#fff',
secondary: {
DEFAULT: "#10B981",
50: "#8CF5D2",
100: "#79F3CB",
200: "#53F0BC",
300: "#2EEDAE",
400: "#13DF9B",
500: "#10B981",
600: "#0C855D",
700: "#075239",
800: "#031E15",
900: "#000000",
950: "#000000",
},
'secondary-foreground': '#161616',
},
},
},
};

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@ui/*": ["./src/components/ui/*"],
"@/*": ["./src/*"],
}
},
"extends": "expo/tsconfig.base"
}