prepared folderstructure for monorepo with express.js

This commit is contained in:
Linus109
2025-12-05 19:32:29 +01:00
parent 14e9aee02f
commit b4ac86068e
37 changed files with 140 additions and 14179 deletions

43
apps/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.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
app-example
# generated native folders
/ios
/android

50
apps/client/app.json Normal file
View File

@@ -0,0 +1,50 @@
{
"expo": {
"jsEngine": "hermes",
"name": "caldav",
"slug": "caldav",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "caldav",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png",
"bundler": "metro"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

3
apps/client/global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname)
module.exports = withNativeWind(config, { input: './global.css' })

1
apps/client/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

50
apps/client/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "@caldav/client",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@caldav/shared": "*",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@shopify/flash-list": "^2.0.2",
"expo": "~54.0.25",
"expo-constants": "~18.0.10",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.9",
"expo-router": "~6.0.15",
"expo-splash-screen": "~31.0.11",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.9",
"nativewind": "^4.2.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.17"
},
"private": true
}

View File

@@ -0,0 +1,28 @@
export const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
export type Month = (typeof MONTHS)[number];
export const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] as const;
export type Day = (typeof DAYS)[number];

View File

@@ -0,0 +1,20 @@
type Theme = {
chatBot: string,
primeFg: string,
primeBg: string,
messageBorderBg: string,
placeholderBg: string,
calenderBg: string,
}
const defaultLight: Theme = {
chatBot: "#DE6C20",
primeFg: "#3B3329",
primeBg: "#FFEEDE",
messageBorderBg: "#FFFFFF",
placeholderBg: "#D9D9D9",
calenderBg: "#FBD5B2",
}
let currentTheme: Theme = defaultLight;
export default currentTheme;

View File

@@ -0,0 +1,308 @@
import { Animated, Modal, Pressable, Text, View } from "react-native";
import { DAYS, MONTHS, Month } from "../Constants";
import Header from "../components/Header";
import React, { useEffect, useMemo, useRef, useState } from "react";
import currentTheme from "../Themes";
import BaseBackground from "../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
// TODO: month selection dropdown menu
const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(0);
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const changeMonth = (delta: number) => {
setMonthIndex((prev) => {
const newIndex = prev + delta;
if (newIndex > 11) {
setCurrentYear((y) => y + 1);
return 0;
}
if (newIndex < 0) {
setCurrentYear((y) => y - 1);
return 11;
}
return newIndex;
});
};
return (
<BaseBackground>
<CalendarHeader
changeMonth={changeMonth}
monthIndex={monthIndex}
currentYear={currentYear}
/>
<WeekDaysLine />
<CalendarGrid month={MONTHS[monthIndex]} year={currentYear} />
</BaseBackground>
);
};
type MonthSelectorProps = {
modalVisible: boolean;
onClose: () => void;
position: { top: number; left: number; width: number };
};
const MonthSelector = ({
modalVisible,
onClose,
position,
}: MonthSelectorProps) => {
const heightAnim = useRef(new Animated.Value(0)).current;
type ItemType = { id: string; text: string };
const listRef = useRef<React.ComponentRef<typeof FlashList<ItemType>>>(null);
const [monthSelectorData, setMonthSelectorData] = useState(() => {
const initial = [];
for (let i = 1; i <= 10; i++) {
initial.push({ id: i.toString(), text: `number ${i}` });
}
return initial;
});
const appendToTestData = (
startIndex: number,
numberOfEntries: number,
appendToStart: boolean,
) => {
// create new data
const newData = [];
for (let i = 0; i < numberOfEntries; i++) {
const newIndex = startIndex + i + 1;
const newEntry = {
id: newIndex + "",
text: `number ${newIndex}`,
};
if (appendToStart) {
newData.unshift(newEntry);
} else {
newData.push(newEntry);
}
}
// add new data
if (appendToStart) {
setMonthSelectorData([...newData, ...monthSelectorData]);
} else {
setMonthSelectorData([...monthSelectorData, ...newData]);
}
};
useEffect(() => {
if (modalVisible) {
Animated.timing(heightAnim, {
toValue: 200,
duration: 200,
useNativeDriver: false,
}).start();
} else {
// reset on close
heightAnim.setValue(0);
}
}, [modalVisible]);
const renderItem = ({ item }: { item: ItemType }) => (
<Pressable>
<View className="w-full flex justify-center items-center">
<Text className="text-3xl">{item.text}</Text>
</View>
</Pressable>
);
return (
<Modal
visible={modalVisible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1" onPress={onClose}>
<Animated.View
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
}}
>
<FlashList
className="w-full"
ref={listRef}
keyExtractor={(item) => item.id}
data={monthSelectorData}
initialScrollIndex={5}
onEndReachedThreshold={0.5}
onEndReached={() =>
appendToTestData(monthSelectorData.length, 10, false)
}
onStartReachedThreshold={0.5}
onStartReached={() =>
appendToTestData(monthSelectorData.length, 10, true)
}
renderItem={renderItem}
/>
</Animated.View>
</Pressable>
</Modal>
);
};
type CalendarHeaderProps = {
changeMonth: (delta: number) => void;
monthIndex: number;
currentYear: number;
};
const CalendarHeader = (props: CalendarHeaderProps) => {
const [modalVisible, setModalVisible] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<View>(null);
const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => props.changeMonth(1);
const measureAndOpen = () => {
containerRef.current?.measureInWindow((x, y, width, height) => {
setDropdownPosition({ top: y + height, left: x, width });
setModalVisible(true);
});
};
return (
<Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} title={"<"} />
<View
ref={containerRef}
className="relative flex flex-row items-center justify-around"
>
<Text className="text-4xl">
{MONTHS[props.monthIndex]} {props.currentYear}
</Text>
<Pressable
className={
"flex justify-center items-center bg-white w-12 h-12 p-2 " +
"border border-solid rounded-full ml-2"
}
style={{
borderColor: currentTheme.primeFg,
}}
onPress={measureAndOpen}
>
<Text className="text-4xl">v</Text>
</Pressable>
</View>
<MonthSelector
modalVisible={modalVisible}
onClose={() => setModalVisible(false)}
position={dropdownPosition}
/>
<ChangeMonthButton onPress={nextMonth} title={">"} />
</Header>
);
};
type ChangeMonthButtonProps = {
onPress: () => void;
title: string;
};
const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
<Pressable
onPress={props.onPress}
className={
"w-16 h-16 bg-white rounded-full flex items-center " +
"justify-center border border-solid border-1 mx-2"
}
style={{
borderColor: currentTheme.primeFg,
}}
>
<Text className="text-4xl">{props.title}</Text>
</Pressable>
);
const WeekDaysLine = () => (
<View className="flex flex-row items-center justify-around px-2 gap-2">
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
{DAYS.map((day, i) => (
<Text key={i}>{day.substring(0, 2).toUpperCase()}</Text>
))}
</View>
);
type CalendarGridProps = {
month: Month;
year: number;
};
const CalendarGrid = (props: CalendarGridProps) => {
const { baseDate, dateOffset } = useMemo(() => {
const monthIndex = MONTHS.indexOf(props.month);
const base = new Date(props.year, monthIndex, 1);
const offset = base.getDay() === 0 ? 6 : base.getDay() - 1;
return { baseDate: base, dateOffset: offset };
}, [props.month, props.year]);
// TODO: create array beforehand in a useMemo
const createDateFromOffset = (offset: number): Date => {
const date = new Date(baseDate);
date.setDate(date.getDate() + offset);
return date;
};
return (
<View
className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
style={{
backgroundColor: currentTheme.calenderBg,
}}
>
{Array.from({ length: 6 }).map((_, i) => (
<View
key={i}
className="w-full flex-1 flex-row justify-around items-center gap-2"
>
{Array.from({ length: 7 }).map((_, j) => (
<SingleDay
key={j}
date={createDateFromOffset(i * 7 + j - dateOffset)}
month={props.month}
/>
))}
</View>
))}
</View>
);
};
type SingleDayProps = {
date: Date;
month: Month;
};
const SingleDay = (props: SingleDayProps) => {
const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
return (
<View
className="h-full flex-1 aspect-auto rounded-xl items-center"
style={{
backgroundColor: currentTheme.primeBg,
}}
>
<Text
className={`text-xl ` + (isSameMonth ? "text-black" : "text-black/50")}
>
{props.date.getDate()}
</Text>
</View>
);
};
export default Calendar;

View File

@@ -0,0 +1,349 @@
import { View, Text, TextInput } from "react-native";
import currentTheme from "../Themes";
import { useState } from "react";
import Header from "../components/Header";
import BaseBackground from "../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
// TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios)
// TODO: max width for messages
// TODO: create new messages
type BubbleSide = "left" | "right";
type ChatMessageProps = {
side: BubbleSide;
width: number;
height: number;
};
type MessageData = {
id: string;
side: BubbleSide;
width: number;
height: number;
};
// NOTE: only for testing
const getRandomInt = (min: number, max: number) => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const randomWidth = () => getRandomInt(100, 400);
const randomHeight = () => getRandomInt(50, 100);
const messages: MessageData[] = [
// {{{
{
id: "1",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "2",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "3",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "4",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "5",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "6",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "7",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "8",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "9",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "10",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
// {
// id: "11",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "12",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "13",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "14",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "15",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "16",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "17",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "18",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "19",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "20",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "21",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "22",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "23",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "24",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "25",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "26",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "27",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "28",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "29",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "30",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "31",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "32",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "33",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "34",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
//, width: randomWidth, height: getRandomInt(50, 500) }}}
];
const Chat = () => {
return (
<BaseBackground>
<ChatHeader />
<FlashList
data={messages}
renderItem={({ item }) => (
<ChatMessage
side={item.side}
width={item.width}
height={item.height}
/>
)}
maintainVisibleContentPosition={{
autoscrollToBottomThreshold: 0.2,
startRenderingFromBottom: true,
}}
keyExtractor={(item) => item.id}
// extraData={selectedId} might need this later for re-rendering
/>
<ChatInput />
</BaseBackground>
);
};
const ChatHeader = () => {
return (
<Header className="flex flex-row items-center">
<View
className="ml-3 w-12 h-12 rounded-3xl border border-solid"
style={{
backgroundColor: currentTheme.placeholderBg,
borderColor: currentTheme.primeFg,
}}
></View>
<Text className="text-lg pl-3">CalChat</Text>
<View
className="h-2 bg-black"
style={{
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 0.34,
shadowRadius: 6.27,
elevation: 10,
}}
/>
</Header>
);
};
const ChatInput = () => {
const [text, onChangeText] = useState("Nachricht");
return (
<View className="flex flex-row w-full h-8 my-2">
<TextInput
className="w-4/5 h-full border border-solid rounded-2xl mx-2 px-2"
style={{
backgroundColor: currentTheme.messageBorderBg,
}}
onChangeText={onChangeText}
value={text}
/>
<View
className="w-8 h-full rounded-2xl"
style={{
backgroundColor: currentTheme.placeholderBg,
}}
></View>
</View>
);
};
const ChatMessage = (props: ChatMessageProps) => {
const borderColor =
props.side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
const selfSide =
props.side === "left"
? "self-start ml-2 rounded-bl-sm"
: "self-end mr-2 rounded-br-sm";
return (
<View
className={
`bg-white border-2 border-solid rounded-xl my-2 ` + `${selfSide}`
}
style={{
width: props.width,
height: props.height,
borderColor: borderColor,
elevation: 8,
}}
>
<Text className="p-1">Lorem Ipsum Dolor sit amet</Text>
</View>
);
};
export default Chat;

View File

@@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { Button, Text, View } from 'react-native';
// const styles = StyleSheet.create({
// container: {
// alignItems: 'center',
// },
// text: {
// fontSize: 40
// }
// })
type HelloWorldProps = {
text: string;
aNumber: number;
}
const HelloWorld = (props: HelloWorldProps) => {
return (
<View className='flex-1 items-center justify-center'>
<Text>{props.text} : {props.aNumber}</Text>
</View>
)
}
const Counter = () => {
const [count, setCount] = useState<number>(0);
return (
<View>
<Button
onPress={() => setCount(count + 1)}
title={`You tabbed me ${count} times`}/>
</View>
)
}
const ManyHelloes = () => {
return (
<View>
<HelloWorld text="first number" aNumber={1}/>
<HelloWorld text="second number" aNumber={2}/>
<HelloWorld text="third number" aNumber={3}/>
<Counter/>
</View>
)
}
export default ManyHelloes;

View File

@@ -0,0 +1,6 @@
import { Stack } from "expo-router";
import "../../global.css";
export default function RootLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import Chat from "./Chat";
import Calender from "./Calender";
export default function Index() {
return (
// <Chat />
<Calender />
);
}

View File

@@ -0,0 +1,23 @@
import { View } from "react-native"
import currentTheme from "../Themes"
import { ReactNode } from "react";
type BaseBackgroundProps = {
children?: ReactNode;
className?: string;
}
const BaseBackground = (props: BaseBackgroundProps) => {
return (
<View
className={`h-full ${props.className}`}
style={{
backgroundColor: currentTheme.primeBg,
}}
>
{props.children}
</View>
)
}
export default BaseBackground;

View File

@@ -0,0 +1,39 @@
import { View } from "react-native";
import currentTheme from "../Themes";
import { ReactNode } from "react";
type HeaderProps = {
children?: ReactNode;
className?: string;
};
const Header = (props: HeaderProps) => {
return (
<View>
<View
className={`w-full h-32 pt-10 pb-4 ${props.className}`}
style={{
backgroundColor: currentTheme.chatBot,
}}
>
{props.children}
</View>
<View
className="h-2 bg-black"
style={{
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 0.34,
shadowRadius: 6.27,
elevation: 10,
}}
/>
</View>
);
};
export default Header;

View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
// NOTE: Update this to include the paths to all files that contain Nativewind classes.
content: ["./app/**/*.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
}

19
apps/client/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}