Compare commits

..

2 Commits

336 changed files with 1055 additions and 3136 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Hosting # Hosting
/docker/data /host/convex/docker/data
# dependencies # dependencies
node_modules node_modules

View File

@@ -31,30 +31,29 @@ I would recommend using [bun](https://bun.sh/) to install dependencies.
bun i bun i
``` ```
You will also need docker installed on whatever host you plan to run the Convex instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Convex SaaS if you want to have a much easier time, probably. I wouldn't know! You will also need docker installed on whatever host you plan to run the Supabase instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Supabase SaaS if you want to have a much easier time, probably. I wouldn't know!
### Add your environment variables ### Add your environment variables
Copy the example environment variable files and paste them in the same directory named `.env`. Copy the example environment variable files and paste them in the same directory named `.env`.
Environment variables for Next Application
```bash ```bash
cp ./apps/next/env.example ./apps/next/.env cp ./app/next/env.example ./app/next/.env
``` ```
Environment variables for Self Hosting Convex & Website with Docker
```bash ```bash
cp ./docker/env.example ./docker/.env cp ./host/convex/docker/env.example ./host/convex/docker/.env
``` ```
### Start self hosted convex & Next Web Application ```bash
cp ./host/next/docker/env.example ./host/next/docker/.env
```
### Start self hosted convex
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted) The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
```bash ```bash
cd ./docker cd ./host/convex/docker
sudo docker compose up -d sudo docker compose up -d
sudo docker compose exec convex-backend ./generate_admin_key.sh sudo docker compose exec convex-backend ./generate_admin_key.sh
``` ```
@@ -63,10 +62,36 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
Run Run
```bash ```bash
bun dev bun dev
```
to start your development environment with turbopack
You can also run
```bash
bun dev:slow
``` ```
to start your development environment with webpack (This is for the next app.)
### Start your Production Environment.
There are Dockerfiles & docker compose files that can be found in the `./scripts/docker` folder for the Next.js website. There is also a script called `reload_container` located in the `./scripts/` folder which was created to quickly update the container, but this will give you a better idea of what you need to do. First, build the image with
```bash
sudo docker compose -f ./host/next/docker/compose.yml build
```
then you can run the container with
```bash
sudo docker compose -f ./host/next/docker/compose up -d
```
Now, you may end up with some build errors. The `reload_containers` script swaps out the next config before it runs the docker build to skip any build errors, so you may want to do this as well, though you are welcome to fix the build errors as well, of course!
### Fin ### Fin
I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here! I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here!

View File

@@ -1,3 +0,0 @@
{
"plugins": ["expo-secure-store"]
}

View File

@@ -1,45 +1,25 @@
{ {
"expo": { "expo": {
"name": "Tech Tracker", "name": "techtracker-expo",
"owner": "gib",
"slug": "techtracker-expo", "slug": "techtracker-expo",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "org.gbrown.techtrackerexpo", "scheme": "techtrackerexpo",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2e2f3d"
},
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"usesAppleSignIn": true, "supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.gbrown.techtracker",
"config": {
"usesNonExemptEncryption": false
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSLocationWhenInUseUsageDescription": "This app uses your location in order to allow you to share your location in chat.",
"NSCameraUsageDescription": "This app uses your camera to take photos & send them in the chat."
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#E6F4FE",
"backgroundColor": "#2e2f3d" "foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"permissions": [ "edgeToEdgeEnabled": true,
"android.permission.ACCESS_COARSE_LOCATION", "predictiveBackGestureEnabled": false
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.INTERNET"
],
"package": "com.gbrown.techtracker"
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -47,7 +27,6 @@
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-apple-authentication",
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
@@ -59,27 +38,6 @@
"backgroundColor": "#000000" "backgroundColor": "#000000"
} }
} }
],
[
"expo-secure-store",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your FaceID biometric data."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
}
],
[
"@sentry/react-native/expo",
{
"url": "https://sentry.gbrown.org",
"note": "Use SENTRY_AUTH_TOKEN env to authenticate with Sentry.",
"project": "tech-tracker-expo",
"organization": "gib"
}
] ]
], ],
"experiments": { "experiments": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -1,5 +0,0 @@
// const { getDefaultConfig } = require("expo/metro-config");
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
// const config = getDefaultConfig(__dirname);
const config = getSentryExpoConfig(__dirname);
module.exports = config;

View File

@@ -3,46 +3,42 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start",
"dev": "expo start", "dev": "expo start",
"dev:tunnel": "expo start --tunnel",
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.2",
"@react-navigation/bottom-tabs": "^7.6.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.7.1", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.19", "@react-navigation/native": "^7.1.8",
"@sentry/react-native": "^7.4.0", "expo": "~54.0.4",
"expo": "~54.0.20", "expo-constants": "~18.0.8",
"expo-apple-authentication": "~8.0.7", "expo-font": "~14.0.8",
"expo-constants": "~18.0.10",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.8",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-location": "~19.0.7", "expo-router": "~6.0.2",
"expo-router": "~6.0.13", "expo-splash-screen": "~31.0.9",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.7",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.4", "react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.3", "react-native-worklets": "0.5.1",
"react-native-safe-area-context": "~5.6.1", "react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2", "react-native-web": "~0.21.0"
"react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.17", "@types/react": "~19.1.0",
"eslint-config-expo": "~10.0.0" "eslint-config-expo": "~10.0.0"
}, },
"private": true "private": true

View File

@@ -15,24 +15,19 @@ export default function TabLayout() {
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false, headerShown: false,
tabBarButton: HapticTab, tabBarButton: HapticTab,
}} }}>
>
<Tabs.Screen <Tabs.Screen
name='index' name="index"
options={{ options={{
title: 'Home', title: 'Home',
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
<IconSymbol size={28} name='house.fill' color={color} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name='explore' name="explore"
options={{ options={{
title: 'Explore', title: 'Explore',
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
<IconSymbol size={28} name='paperplane.fill' color={color} />
),
}} }}
/> />
</Tabs> </Tabs>

View File

@@ -1,5 +1,6 @@
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native'; import { Platform, StyleSheet } from 'react-native';
import { Collapsible } from '@/components/ui/collapsible'; import { Collapsible } from '@/components/ui/collapsible';
import { ExternalLink } from '@/components/external-link'; import { ExternalLink } from '@/components/external-link';
import ParallaxScrollView from '@/components/parallax-scroll-view'; import ParallaxScrollView from '@/components/parallax-scroll-view';
@@ -15,82 +16,71 @@ export default function TabTwoScreen() {
headerImage={ headerImage={
<IconSymbol <IconSymbol
size={310} size={310}
color='#808080' color="#808080"
name='chevron.left.forwardslash.chevron.right' name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage} style={styles.headerImage}
/> />
} }>
>
<ThemedView style={styles.titleContainer}> <ThemedView style={styles.titleContainer}>
<ThemedText <ThemedText
type='title' type="title"
style={{ style={{
fontFamily: Fonts.rounded, fontFamily: Fonts.rounded,
}} }}>
>
Explore Explore
</ThemedText> </ThemedText>
</ThemedView> </ThemedView>
<ThemedText> <ThemedText>This app includes example code to help you get started.</ThemedText>
This app includes example code to help you get started. <Collapsible title="File-based routing">
</ThemedText>
<Collapsible title='File-based routing'>
<ThemedText> <ThemedText>
This app has two screens:{' '} This app has two screens:{' '}
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '} <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
and{' '} <ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
<ThemedText type='defaultSemiBold'>app/(tabs)/explore.tsx</ThemedText>
</ThemedText> </ThemedText>
<ThemedText> <ThemedText>
The layout file in{' '} The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
<ThemedText type='defaultSemiBold'>app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator. sets up the tab navigator.
</ThemedText> </ThemedText>
<ExternalLink href='https://docs.expo.dev/router/introduction'> <ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type='link'>Learn more</ThemedText> <ThemedText type="link">Learn more</ThemedText>
</ExternalLink> </ExternalLink>
</Collapsible> </Collapsible>
<Collapsible title='Android, iOS, and web support'> <Collapsible title="Android, iOS, and web support">
<ThemedText> <ThemedText>
You can open this project on Android, iOS, and the web. To open the You can open this project on Android, iOS, and the web. To open the web version, press{' '}
web version, press <ThemedText type='defaultSemiBold'>w</ThemedText>{' '} <ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
in the terminal running this project.
</ThemedText> </ThemedText>
</Collapsible> </Collapsible>
<Collapsible title='Images'> <Collapsible title="Images">
<ThemedText> <ThemedText>
For static images, you can use the{' '} For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type='defaultSemiBold'>@2x</ThemedText> and{' '} <ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
<ThemedText type='defaultSemiBold'>@3x</ThemedText> suffixes to different screen densities
provide files for different screen densities
</ThemedText> </ThemedText>
<Image <Image
source={require('assets/images/react-logo.png')} source={require('@/assets/images/react-logo.png')}
style={{ width: 100, height: 100, alignSelf: 'center' }} style={{ width: 100, height: 100, alignSelf: 'center' }}
/> />
<ExternalLink href='https://reactnative.dev/docs/images'> <ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type='link'>Learn more</ThemedText> <ThemedText type="link">Learn more</ThemedText>
</ExternalLink> </ExternalLink>
</Collapsible> </Collapsible>
<Collapsible title='Light and dark mode components'> <Collapsible title="Light and dark mode components">
<ThemedText> <ThemedText>
This template has light and dark mode support. The{' '} This template has light and dark mode support. The{' '}
<ThemedText type='defaultSemiBold'>useColorScheme()</ThemedText> hook <ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
lets you inspect what the user&apos;s current color scheme is, and so what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
you can adjust UI colors accordingly.
</ThemedText> </ThemedText>
<ExternalLink href='https://docs.expo.dev/develop/user-interface/color-themes/'> <ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type='link'>Learn more</ThemedText> <ThemedText type="link">Learn more</ThemedText>
</ExternalLink> </ExternalLink>
</Collapsible> </Collapsible>
<Collapsible title='Animations'> <Collapsible title="Animations">
<ThemedText> <ThemedText>
This template includes an example of an animated component. The{' '} This template includes an example of an animated component. The{' '}
<ThemedText type='defaultSemiBold'> <ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
components/HelloWave.tsx the powerful{' '}
</ThemedText>{' '} <ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
component uses the powerful{' '}
<ThemedText type='defaultSemiBold' style={{ fontFamily: Fonts.mono }}>
react-native-reanimated react-native-reanimated
</ThemedText>{' '} </ThemedText>{' '}
library to create a waving hand animation. library to create a waving hand animation.
@@ -98,10 +88,7 @@ export default function TabTwoScreen() {
{Platform.select({ {Platform.select({
ios: ( ios: (
<ThemedText> <ThemedText>
The{' '} The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
<ThemedText type='defaultSemiBold'>
components/ParallaxScrollView.tsx
</ThemedText>{' '}
component provides a parallax effect for the header image. component provides a parallax effect for the header image.
</ThemedText> </ThemedText>
), ),

View File

@@ -1,5 +1,6 @@
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native'; import { Platform, StyleSheet } from 'react-native';
import { HelloWave } from '@/components/hello-wave'; import { HelloWave } from '@/components/hello-wave';
import ParallaxScrollView from '@/components/parallax-scroll-view'; import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text'; import { ThemedText } from '@/components/themed-text';
@@ -12,22 +13,20 @@ export default function HomeScreen() {
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }} headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
headerImage={ headerImage={
<Image <Image
source={require('assets/images/partial-react-logo.png')} source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo} style={styles.reactLogo}
/> />
} }>
>
<ThemedView style={styles.titleContainer}> <ThemedView style={styles.titleContainer}>
<ThemedText type='title'>Welcome!</ThemedText> <ThemedText type="title">Welcome!</ThemedText>
<HelloWave /> <HelloWave />
</ThemedView> </ThemedView>
<ThemedView style={styles.stepContainer}> <ThemedView style={styles.stepContainer}>
<ThemedText type='subtitle'>Step 1: Try it</ThemedText> <ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText> <ThemedText>
Edit{' '} Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '} Press{' '}
to see changes. Press{' '} <ThemedText type="defaultSemiBold">
<ThemedText type='defaultSemiBold'>
{Platform.select({ {Platform.select({
ios: 'cmd + d', ios: 'cmd + d',
android: 'cmd + m', android: 'cmd + m',
@@ -38,26 +37,22 @@ export default function HomeScreen() {
</ThemedText> </ThemedText>
</ThemedView> </ThemedView>
<ThemedView style={styles.stepContainer}> <ThemedView style={styles.stepContainer}>
<Link href='/modal'> <Link href="/modal">
<Link.Trigger> <Link.Trigger>
<ThemedText type='subtitle'>Step 2: Explore</ThemedText> <ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger> </Link.Trigger>
<Link.Preview /> <Link.Preview />
<Link.Menu> <Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
<Link.MenuAction <Link.MenuAction
title='Action' title="Share"
icon='cube' icon="square.and.arrow.up"
onPress={() => alert('Action pressed')}
/>
<Link.MenuAction
title='Share'
icon='square.and.arrow.up'
onPress={() => alert('Share pressed')} onPress={() => alert('Share pressed')}
/> />
<Link.Menu title='More' icon='ellipsis'> <Link.Menu title="More" icon="ellipsis">
<Link.MenuAction <Link.MenuAction
title='Delete' title="Delete"
icon='trash' icon="trash"
destructive destructive
onPress={() => alert('Delete pressed')} onPress={() => alert('Delete pressed')}
/> />
@@ -70,16 +65,13 @@ export default function HomeScreen() {
</ThemedText> </ThemedText>
</ThemedView> </ThemedView>
<ThemedView style={styles.stepContainer}> <ThemedView style={styles.stepContainer}>
<ThemedText type='subtitle'>Step 3: Get a fresh start</ThemedText> <ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText> <ThemedText>
{`When you're ready, run `} {`When you're ready, run `}
<ThemedText type='defaultSemiBold'> <ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
npm run reset-project <ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
</ThemedText>{' '} <ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
to get a fresh <ThemedText type='defaultSemiBold'>app</ThemedText>{' '} <ThemedText type="defaultSemiBold">app-example</ThemedText>.
directory. This will move the current{' '}
<ThemedText type='defaultSemiBold'>app</ThemedText> to{' '}
<ThemedText type='defaultSemiBold'>app-example</ThemedText>.
</ThemedText> </ThemedText>
</ThemedView> </ThemedView>
</ParallaxScrollView> </ParallaxScrollView>

View File

@@ -1,44 +1,24 @@
import { import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import * as Sentry from '@sentry/react-native';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
Sentry.init({
dsn: 'https://ff2e19b7c72ee50463c6c66b5bef7ce0@sentry.gbrown.org/8',
sendDefaultPii: true,
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
});
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
export const unstable_settings = { export const unstable_settings = {
anchor: '(tabs)', anchor: '(tabs)',
}; };
const RootLayout = () => { export default function RootLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
return ( return (
<ConvexProvider client={convex}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack> <Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
name='modal'
options={{ presentation: 'modal', title: 'Modal' }}
/>
</Stack> </Stack>
<StatusBar style='auto' /> <StatusBar style="auto" />
</ThemeProvider> </ThemeProvider>
</ConvexProvider>
); );
}; }
export default Sentry.wrap(RootLayout);

View File

@@ -7,9 +7,9 @@ import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() { export default function ModalScreen() {
return ( return (
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type='title'>This is a modal</ThemedText> <ThemedText type="title">This is a modal</ThemedText>
<Link href='/' dismissTo style={styles.link}> <Link href="/" dismissTo style={styles.link}>
<ThemedText type='link'>Go to home screen</ThemedText> <ThemedText type="link">Go to home screen</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
); );

View File

@@ -1,18 +1,13 @@
import { Href, Link } from 'expo-router'; import { Href, Link } from 'expo-router';
import { import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
openBrowserAsync,
WebBrowserPresentationStyle,
} from 'expo-web-browser';
import { type ComponentProps } from 'react'; import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
href: Href & string;
};
export function ExternalLink({ href, ...rest }: Props) { export function ExternalLink({ href, ...rest }: Props) {
return ( return (
<Link <Link
target='_blank' target="_blank"
{...rest} {...rest}
href={href} href={href}
onPress={async (event) => { onPress={async (event) => {

View File

@@ -12,8 +12,7 @@ export function HelloWave() {
}, },
animationIterationCount: 4, animationIterationCount: 4,
animationDuration: '300ms', animationDuration: '300ms',
}} }}>
>
👋 👋
</Animated.Text> </Animated.Text>
); );

View File

@@ -34,15 +34,11 @@ export default function ParallaxScrollView({
translateY: interpolate( translateY: interpolate(
scrollOffset.value, scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT], [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75], [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
), ),
}, },
{ {
scale: interpolate( scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1],
),
}, },
], ],
}; };
@@ -52,15 +48,13 @@ export default function ParallaxScrollView({
<Animated.ScrollView <Animated.ScrollView
ref={scrollRef} ref={scrollRef}
style={{ backgroundColor, flex: 1 }} style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16} scrollEventThrottle={16}>
>
<Animated.View <Animated.View
style={[ style={[
styles.header, styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] }, { backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle, headerAnimatedStyle,
]} ]}>
>
{headerImage} {headerImage}
</Animated.View> </Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView> <ThemedView style={styles.content}>{children}</ThemedView>

View File

@@ -7,16 +7,8 @@ export type ThemedViewProps = ViewProps & {
darkColor?: string; darkColor?: string;
}; };
export function ThemedView({ export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
style, const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
const backgroundColor = useThemeColor(
{ light: lightColor, dark: darkColor },
'background',
);
return <View style={[{ backgroundColor }, style]} {...otherProps} />; return <View style={[{ backgroundColor }, style]} {...otherProps} />;
} }

View File

@@ -7,10 +7,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
children,
title,
}: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light'; const theme = useColorScheme() ?? 'light';
@@ -19,17 +16,16 @@ export function Collapsible({
<TouchableOpacity <TouchableOpacity
style={styles.heading} style={styles.heading}
onPress={() => setIsOpen((value) => !value)} onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8} activeOpacity={0.8}>
>
<IconSymbol <IconSymbol
name='chevron.right' name="chevron.right"
size={18} size={18}
weight='medium' weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon} color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }} style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/> />
<ThemedText type='defaultSemiBold'>{title}</ThemedText> <ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity> </TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>} {isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView> </ThemedView>

View File

@@ -18,7 +18,7 @@ export function IconSymbol({
<SymbolView <SymbolView
weight={weight} weight={weight}
tintColor={color} tintColor={color}
resizeMode='scaleAspectFit' resizeMode="scaleAspectFit"
name={name} name={name}
style={[ style={[
{ {

View File

@@ -5,10 +5,7 @@ import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record< type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
SymbolViewProps['name'],
ComponentProps<typeof MaterialIcons>['name']
>;
type IconSymbolName = keyof typeof MAPPING; type IconSymbolName = keyof typeof MAPPING;
/** /**
@@ -40,12 +37,5 @@ export function IconSymbol({
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
weight?: SymbolWeight; weight?: SymbolWeight;
}) { }) {
return ( return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
<MaterialIcons
color={color}
size={size}
name={MAPPING[name]}
style={style}
/>
);
} }

View File

@@ -47,8 +47,7 @@ export const Fonts = Platform.select({
web: { web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif", serif: "Georgia, 'Times New Roman', serif",
rounded: rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
}, },
}); });

View File

@@ -8,7 +8,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor( export function useThemeColor(
props: { light?: string; dark?: string }, props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark, colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) { ) {
const theme = useColorScheme() ?? 'light'; const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme]; const colorFromProps = props[theme];

View File

@@ -3,13 +3,14 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"baseUrl": ".", "baseUrl": ".",
"jsx": "react-jsx",
"esModuleInterop": true,
"paths": { "paths": {
"assets/*": ["./assets/*"], "@/*": ["./src/*"]
"@/*": ["./src/*"],
"~/*": ["../../packages/backend/*"]
} }
}, },
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] "include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
} }

8
apps/next/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
node_modules
.next
dist
coverage
*.log
docker-compose*.yml
host/

View File

@@ -45,6 +45,3 @@ next-env.d.ts
# Ignored for the template, you probably want to remove it: # Ignored for the template, you probably want to remove it:
package-lock.json package-lock.json
# Sentry Config File
.env.sentry-build-plugin

View File

@@ -1,19 +1,18 @@
### Server Variables ### ### Server Variables ###
# Next # Convex
NODE_ENV= CONVEX_SELF_HOSTED_URL=
SKIP_ENV_VALIDATION= CONVEX_SELF_HOSTED_ADMIN_KEY=
SITE_URL= NEXT_PUBLIC_CONVEX_URL=
SETUP_SCRIPT_RAN=
# Sentry # Sentry
SENTRY_AUTH_TOKEN= SENTRY_AUTH_TOKEN=
CI=
### Client Variables ### ### Client Variables ###
# Next # Next # Default Values:
NEXT_PUBLIC_SITE_URL= NEXT_PUBLIC_SITE_URL='http://localhost:3000'
# Convex # Sentry # Default Values
NEXT_PUBLIC_CONVEX_URL=
# Sentry
NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL= NEXT_PUBLIC_SENTRY_URL=
NEXT_PUBLIC_SENTRY_ORG= NEXT_PUBLIC_SENTRY_ORG=
NEXT_PUBLIC_SENTRY_PROJECT_NAME= NEXT_PUBLIC_SENTRY_PROJECT_NAME=

View File

@@ -1,4 +1,4 @@
import { env } from './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible'; import { withPlausibleProxy } from 'next-plausible';
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
const sentryConfig = { const sentryConfig = {
// For all available options, see: // For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options // https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: env.NEXT_PUBLIC_SENTRY_ORG, org: 'gib',
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME, project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL, sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: env.SENTRY_AUTH_TOKEN, authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI // Only print logs for uploading source maps in CI
silent: !env.CI, silent: !process.env.CI,
// For all available options, see: // For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time) // Upload a larger set of source maps for prettier stack traces (increases build time)

View File

@@ -5,7 +5,6 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev --turbo",
"dev:tunnel": "next dev --turbo",
"dev:slow": "next dev", "dev:slow": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
@@ -14,9 +13,8 @@
}, },
"dependencies": { "dependencies": {
"@convex-dev/auth": "^0.0.81", "@convex-dev/auth": "^0.0.81",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.1",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
@@ -24,41 +22,39 @@
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@sentry/nextjs": "^10.22.0", "@sentry/nextjs": "^10.11.0",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.28.0", "convex": "^1.27.0",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"input-otp": "^1.4.2",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"next": "^15.5.6", "next": "^15.5.3",
"next-plausible": "^3.12.4", "next-plausible": "^3.12.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.1.1",
"react-dom": "^19.2.0", "react-dom": "^19.1.1",
"react-hook-form": "^7.65.0", "react-hook-form": "^7.62.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.43.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.12" "zod": "^4.1.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.1.13",
"@types/node": "^20.19.23", "@types/node": "^20.19.13",
"@types/react": "^19.2.2", "@types/react": "^19.1.12",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.1.9",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"eslint-config-next": "^15.5.6", "eslint-config-next": "^15.5.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.3.8"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

View File

@@ -1,9 +1,9 @@
import { env } from './src/env.js'; // https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import './src/env.js';
Sentry.init({ Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN, dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1, tracesSampleRate: 1,
enableLogs: true,
debug: false, debug: false,
}); });

View File

@@ -1,14 +0,0 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password',
};
};
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ProfileLayout;

View File

@@ -1,298 +0,0 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSlot,
SubmitButton,
InputOTPSeparator,
} from '@/components/ui';
import { toast } from 'sonner';
import { PASSWORD_MIN, PASSWORD_MAX } from '@/lib/types';
const forgotPasswordSchema = z.object({
email: z.email({ message: 'Invalid email.' }),
});
const resetVerificationSchema = z
.object({
code: z.string({ message: 'Invalid code.' }),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const ForgotPassword = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
const [email, setEmail] = useState<string>('');
const [loading, setLoading] = useState(false);
const [code, setCode] = useState<string>('');
const router = useRouter();
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email },
});
const resetVerificationForm = useForm<
z.infer<typeof resetVerificationSchema>
>({
resolver: zodResolver(resetVerificationSchema),
defaultValues: { code, newPassword: '', confirmPassword: '' },
});
const handleForgotPasswordSubmit = async (
values: z.infer<typeof forgotPasswordSchema>,
) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
const handleResetVerificationSubmit = async (
values: z.infer<typeof resetVerificationSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('newPassword', values.newPassword);
formData.append('email', email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData);
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[400px] w-sm lg:w-md'>
<CardHeader className='flex flex-col gap-4 items-center'>
{flow === 'reset' ? (
<>
<CardTitle className='font-bold text-2xl'>
Forgot Password
</CardTitle>
<CardDescription>
Enter your email address and we will send you a link to reset
your password.
</CardDescription>
</>
) : (
<>
<CardTitle className='font-bold text-2xl'>
Reset Password
</CardTitle>
<CardDescription>
Enter your code and new password and we will reset your
password.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
<Card className='bg-card/50'>
<CardContent>
{flow === 'reset' ? (
<Form {...forgotPasswordForm}>
<form
onSubmit={forgotPasswordForm.handleSubmit(
handleForgotPasswordSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={forgotPasswordForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Sending Email...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Send Email
</SubmitButton>
</form>
</Form>
) : (
<Form {...resetVerificationForm}>
<form
onSubmit={resetVerificationForm.handleSubmit(
handleResetVerificationSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={resetVerificationForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
</FormDescription>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
New Password
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Resetting Password...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Reset Password
</SubmitButton>
</form>
</Form>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
};
export default ForgotPassword;

View File

@@ -18,7 +18,8 @@ const Profile = async () => {
<AvatarUpload preloadedUser={preloadedUser} /> <AvatarUpload preloadedUser={preloadedUser} />
<Separator /> <Separator />
<UserInfoForm preloadedUser={preloadedUser} /> <UserInfoForm preloadedUser={preloadedUser} />
<ResetPasswordForm preloadedUser={preloadedUser} /> <Separator />
<ResetPasswordForm />
<Separator /> <Separator />
<SignOutForm /> <SignOutForm />
</Card> </Card>

View File

@@ -12,17 +12,11 @@ import {
CardContent, CardContent,
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
Input, Input,
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
Separator,
SubmitButton, SubmitButton,
Tabs, Tabs,
TabsContent, TabsContent,
@@ -30,10 +24,6 @@ import {
TabsTrigger, TabsTrigger,
} from '@/components/ui'; } from '@/components/ui';
import { toast } from 'sonner'; import { toast } from 'sonner';
import {
GibsAuthSignInButton,
MicrosoftSignInButton,
} from '@/components/layout/auth/buttons';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types'; import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
const signInFormSchema = z.object({ const signInFormSchema = z.object({
@@ -50,7 +40,7 @@ const signUpFormSchema = z
name: z.string().min(2, { name: z.string().min(2, {
message: 'Name must be at least 2 characters.', message: 'Name must be at least 2 characters.',
}), }),
email: z.email({ email: z.string().email({
message: 'Please enter a valid email address.', message: 'Please enter a valid email address.',
}), }),
password: z password: z
@@ -85,17 +75,9 @@ const signUpFormSchema = z
path: ['confirmPassword'], path: ['confirmPassword'],
}); });
const verifyEmailFormSchema = z.object({
code: z.string({ message: 'Invalid code.' }),
});
const SignIn = () => { const SignIn = () => {
const { signIn } = useAuthActions(); const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>( const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
'signIn',
);
const [email, setEmail] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -108,17 +90,12 @@ const SignIn = () => {
resolver: zodResolver(signUpFormSchema), resolver: zodResolver(signUpFormSchema),
defaultValues: { defaultValues: {
name: '', name: '',
email, email: '',
password: '', password: '',
confirmPassword: '', confirmPassword: '',
}, },
}); });
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
resolver: zodResolver(verifyEmailFormSchema),
defaultValues: { code },
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => { const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData(); const formData = new FormData();
formData.append('email', values.email); formData.append('email', values.email);
@@ -126,12 +103,13 @@ const SignIn = () => {
formData.append('flow', flow); formData.append('flow', flow);
setLoading(true); setLoading(true);
try { try {
await signIn('password', formData).then(() => router.push('/')); await signIn('password', formData);
signInForm.reset();
router.push('/');
} catch (error) { } catch (error) {
console.error('Error signing in:', error); console.error('Error signing in:', error);
toast.error('Error signing in.'); toast.error('Error signing in.');
} finally { } finally {
signInForm.reset();
setLoading(false); setLoading(false);
} }
}; };
@@ -146,107 +124,17 @@ const SignIn = () => {
try { try {
if (values.confirmPassword !== values.password) if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.'); throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => { await signIn('password', formData);
setEmail(values.email); signUpForm.reset();
setFlow('email-verification'); router.push('/');
});
} catch (error) { } catch (error) {
console.error('Error signing up:', error); console.error('Error signing up:', error);
toast.error('Error signing up.'); toast.error('Error signing up.');
} finally { } finally {
signUpForm.reset();
setLoading(false); setLoading(false);
} }
}; };
const handleVerifyEmail = async (
values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('flow', flow);
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
if (flow === 'email-verification') {
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
<CardContent>
<div className='text-center mb-6'>
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
<p className='text-muted-foreground'>We sent a code to {email}</p>
</div>
<Form {...verifyEmailForm}>
<form
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
className='flex flex-col space-y-8'
>
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your email.
</FormDescription>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Verify Email
</SubmitButton>
</form>
</Form>
<div className='text-center mt-4'>
<button
onClick={() => setFlow('signUp')}
className='text-sm text-muted-foreground hover:underline'
>
Back to Sign Up
</button>
</div>
</CardContent>
</Card>
</div>
);
}
return ( return (
<div className='flex flex-col items-center'> <div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'> <Card className='p-4 bg-card/25 min-h-[720px] w-md'>
@@ -323,28 +211,12 @@ const SignIn = () => {
<SubmitButton <SubmitButton
disabled={loading} disabled={loading}
pendingText='Signing in...' pendingText='Signing in...'
className='text-xl font-semibold w-2/3 mx-auto' className='text-lg font-semibold w-2/3 mx-auto'
> >
Sign In Sign In
</SubmitButton> </SubmitButton>
</form> </form>
</Form> </Form>
<div className='flex justify-center'>
<div
className='flex flex-row items-center
my-2.5 mx-auto justify-center w-1/4'
>
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div>
</div>
<div className='flex justify-center mb-3'>
<MicrosoftSignInButton />
</div>
<div className='flex justify-center mt-3'>
<GibsAuthSignInButton />
</div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -437,25 +309,12 @@ const SignIn = () => {
<SubmitButton <SubmitButton
disabled={loading} disabled={loading}
pendingText='Signing Up...' pendingText='Signing Up...'
className='text-xl font-semibold w-2/3 mx-auto' className='text-lg font-semibold w-2/3 mx-auto'
> >
Sign Up Sign Up
</SubmitButton> </SubmitButton>
</form> </form>
</Form> </Form>
<div className='flex my-auto justify-center w-2/3'>
<div className='flex flex-row w-1/3 items-center my-2.5'>
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div>
</div>
<div className='flex justify-center mb-3'>
<MicrosoftSignInButton type='signUp' />
</div>
<div className='flex justify-center mt-3'>
<GibsAuthSignInButton type='signUp' />
</div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View File

@@ -38,6 +38,7 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
Sentry.captureException(error); Sentry.captureException(error);
}, [error]); }, [error]);
return ( return (
<ConvexClientProvider>
<PlausibleProvider <PlausibleProvider
domain='techtracker.gbrown.org' domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org' customDomain='https://plausible.gbrown.org'
@@ -68,6 +69,7 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
</body> </body>
</html> </html>
</PlausibleProvider> </PlausibleProvider>
</ConvexClientProvider>
); );
}; };
export default GlobalError; export default GlobalError;

View File

@@ -4,8 +4,6 @@ import '@/styles/globals.css';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server'; import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import { import {
ConvexClientProvider, ConvexClientProvider,
LunchReminder,
NotificationsPermission,
ThemeProvider, ThemeProvider,
TVModeProvider, TVModeProvider,
} from '@/components/providers'; } from '@/components/providers';
@@ -24,11 +22,11 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = generateMetadata(); export const metadata: Metadata = generateMetadata();
const RootLayout = async ({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) => { }>) {
return ( return (
<ConvexAuthNextjsServerProvider> <ConvexAuthNextjsServerProvider>
<PlausibleProvider <PlausibleProvider
@@ -50,8 +48,6 @@ const RootLayout = async ({
<Header /> <Header />
{children} {children}
<Toaster /> <Toaster />
<NotificationsPermission />
<LunchReminder />
</TVModeProvider> </TVModeProvider>
</ConvexClientProvider> </ConvexClientProvider>
</ThemeProvider> </ThemeProvider>
@@ -60,5 +56,4 @@ const RootLayout = async ({
</PlausibleProvider> </PlausibleProvider>
</ConvexAuthNextjsServerProvider> </ConvexAuthNextjsServerProvider>
); );
}; }
export default RootLayout;

View File

@@ -1,39 +0,0 @@
import Image from 'next/image';
import { useAuthActions } from '@convex-dev/auth/react';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type Props = {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
};
export const GibsAuthSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('authentik')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='flex flex-row my-auto space-x-1'>
<Image
src={'/icons/misc/gibs-auth-logo.png'}
className=''
alt="Gib's Auth"
width={30}
height={30}
/>
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Gib&apos;s Auth</p>
</div>
</Button>
);
};

View File

@@ -1,2 +0,0 @@
export { GibsAuthSignInButton } from './gibs-auth';
export { MicrosoftSignInButton } from './microsoft';

View File

@@ -1,39 +0,0 @@
import { useAuthActions } from '@convex-dev/auth/react';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type Props = {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
};
export const MicrosoftSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('microsoft-entra-id')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='flex flex-row my-auto space-x-2'>
<div className='flex flex-row my-auto'>
<svg className='scale-150' viewBox='0 0 23 23'>
<path fill='#f35325' d='M1 1h10v10H1z' />
<path fill='#81bc06' d='M12 1h10v10H12z' />
<path fill='#05a6f0' d='M1 12h10v10H1z' />
<path fill='#ffba08' d='M12 12h10v10H12z' />
</svg>
</div>
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Microsoft</p>
</div>
</Button>
);
};

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react'; import { type ChangeEvent, useRef, useState } from 'react';
import { import {
type Preloaded, type Preloaded,
@@ -9,14 +10,13 @@ import {
} from 'convex/react'; } from 'convex/react';
import { api } from '~/convex/_generated/api'; import { api } from '~/convex/_generated/api';
import { import {
Avatar,
AvatarImage,
BasedAvatar, BasedAvatar,
Button, Button,
CardContent, CardContent,
ImageCrop, ImageCrop,
ImageCropApply, ImageCropApply,
ImageCropContent, ImageCropContent,
ImageCropReset,
Input, Input,
} from '@/components/ui'; } from '@/components/ui';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -48,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl); const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser); const updateUserImage = useMutation(api.auth.updateUserImage);
const currentImageUrl = useQuery( const currentImageUrl = useQuery(
api.files.getImageUrl, api.files.getImageUrl,
@@ -97,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
storageId: Id<'_storage'>; storageId: Id<'_storage'>;
}; };
await updateUser({ image: uploadResponse.storageId }); await updateUserImage({ storageId: uploadResponse.storageId });
toast.success('Profile picture updated.'); toast.success('Profile picture updated.');
handleReset(); handleReset();
@@ -121,7 +121,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
<BasedAvatar <BasedAvatar
src={currentImageUrl ?? undefined} src={currentImageUrl ?? undefined}
fullName={user?.name} fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold' className='h-32 w-32'
fallbackProps={{ className: 'text-4xl font-semibold' }}
userIconProps={{ size: 100 }} userIconProps={{ size: 100 }}
/> />
<div <div
@@ -172,6 +173,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
<ImageCropContent className='max-w-sm' /> <ImageCropContent className='max-w-sm' />
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<ImageCropApply /> <ImageCropApply />
<ImageCropReset />
<Button <Button
onClick={handleReset} onClick={handleReset}
size='icon' size='icon'
@@ -188,14 +190,19 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
{/* Cropped preview + actions */} {/* Cropped preview + actions */}
{croppedImage && ( {croppedImage && (
<div className='flex flex-col items-center gap-3'> <div className='flex flex-col items-center gap-3'>
<Avatar className='h-42 w-42'> <Image
<AvatarImage alt='Cropped preview' src={croppedImage} /> alt='Cropped preview'
</Avatar> className='overflow-hidden rounded-full'
<div className='flex items-center gap-1'> height={128}
src={croppedImage}
unoptimized
width={128}
/>
<div className='flex items-center gap-2'>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isUploading} disabled={isUploading}
className='px-4' className='px-6'
> >
{isUploading ? ( {isUploading ? (
<span className='inline-flex items-center gap-2'> <span className='inline-flex items-center gap-2'>
@@ -210,10 +217,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={handleReset} onClick={handleReset}
size='icon' size='icon'
type='button' type='button'
className='dark:bg-red-500/30 bg-red-400/80 variant='ghost'
hover:dark:text-red-300/60 hover:text-red-800/80
hover:dark:bg-accent'
variant='secondary'
> >
<XIcon className='size-4' /> <XIcon className='size-4' />
</Button> </Button>

View File

@@ -3,7 +3,6 @@ import { useState } from 'react';
import { useAction } from 'convex/react'; import { useAction } from 'convex/react';
import { api } from '~/convex/_generated/api'; import { api } from '~/convex/_generated/api';
import { z } from 'zod'; import { z } from 'zod';
import { type Preloaded, usePreloadedQuery } from 'convex/react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
@@ -19,7 +18,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
Input, Input,
Separator,
SubmitButton, SubmitButton,
} from '@/components/ui'; } from '@/components/ui';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -64,12 +62,7 @@ const formSchema = z
path: ['confirmPassword'], path: ['confirmPassword'],
}); });
type ResetFormProps = { export const ResetPasswordForm = () => {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const changePassword = useAction(api.auth.updateUserPassword); const changePassword = useAction(api.auth.updateUserPassword);
@@ -101,12 +94,10 @@ export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
setLoading(false); setLoading(false);
} }
}; };
return user?.provider !== 'password' ? (
<div /> return (
) : (
<> <>
<Separator /> <CardHeader className='pb-5'>
<CardHeader>
<CardTitle className='text-2xl'>Change Password</CardTitle> <CardTitle className='text-2xl'>Change Password</CardTitle>
<CardDescription> <CardDescription>
Update your password to keep your account secure Update your password to keep your account secure

View File

@@ -1,5 +1,5 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react'; import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
import { api } from '~/convex/_generated/api'; import { api } from '~/convex/_generated/api';
import { z } from 'zod'; import { z } from 'zod';
@@ -7,9 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
CardContent, CardContent,
CardDescription,
CardHeader,
CardTitle,
Form, Form,
FormControl, FormControl,
FormDescription, FormDescription,
@@ -19,7 +16,6 @@ import {
FormMessage, FormMessage,
Input, Input,
SubmitButton, SubmitButton,
Switch,
} from '@/components/ui'; } from '@/components/ui';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -36,11 +32,6 @@ const formSchema = z.object({
email: z.email({ email: z.email({
message: 'Please enter a valid email address.', message: 'Please enter a valid email address.',
}), }),
lunchTime: z
.string()
.trim()
.min(3, { message: 'Must be a valid 24-hour time. Example: 13:00' }),
automaticLunch: z.boolean(),
}); });
type UserInfoFormProps = { type UserInfoFormProps = {
@@ -51,47 +42,28 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser); const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser); const updateUserName = useMutation(api.auth.updateUserName);
const updateUserEmail = useMutation(api.auth.updateUserEmail);
const initialValues = useMemo<z.infer<typeof formSchema>>(
() => ({
name: user?.name ?? '',
email: user?.email ?? '',
lunchTime: user?.lunchTime ?? '12:00',
automaticLunch: user?.automaticLunch ?? false,
}),
[user?.name, user?.email, user?.lunchTime, user?.automaticLunch],
);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: initialValues, defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
}); });
const handleSubmit = async (values: z.infer<typeof formSchema>) => { const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = [];
const name = values.name.trim(); const name = values.name.trim();
const email = values.email.trim().toLowerCase(); const email = values.email.trim().toLowerCase();
const lunchTime = values.lunchTime.trim(); if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
const automaticLunch = values.automaticLunch; if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
const patch: Partial<{ if (ops.length === 0) return;
name: string;
email: string;
lunchTime: string;
automaticLunch: boolean;
}> = {};
if (name !== (user?.name ?? '') && name !== undefined)
patch.name = name;
if (email !== (user?.email ?? '') && email !== undefined)
patch.email = email;
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
patch.lunchTime = lunchTime;
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
patch.automaticLunch = automaticLunch;
if (Object.keys(patch).length === 0) return;
setLoading(true); setLoading(true);
try { try {
await updateUser(patch); await Promise.all(ops);
form.reset(patch); form.reset({ name, email });
toast.success('Profile updated successfully.'); toast.success('Profile updated successfully.');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -102,17 +74,9 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
}; };
return ( return (
<>
<CardHeader>
<CardTitle className='text-2xl'>Account Information</CardTitle>
<CardDescription>Update your account information here.</CardDescription>
</CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
>
<FormField <FormField
control={form.control} control={form.control}
name='name' name='name'
@@ -135,10 +99,7 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
disabled={user?.provider !== 'password'}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your email address associated with your account. Your email address associated with your account.
@@ -147,62 +108,14 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<div className='flex flex-row justify-center space-x-10'>
<FormField
control={form.control}
name='lunchTime'
render={({ field }) => (
<FormItem className='sm:w-2/5'>
<div className='flex flex-row space-x-2 my-auto'>
<FormLabel>Lunch Time</FormLabel>
<FormControl>
<Input type='time' className='w-28' {...field} />
</FormControl>
</div>
<FormDescription>Your regular lunch time.</FormDescription>
<FormDescription className='dark:text-red-300/60 text-red-800/80'>
{!user?.lunchTime && 'Not currently set.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='automaticLunch'
render={({ field }) => (
<FormItem className='w-2/5 mt-2'>
<div className='flex flex-row space-x-2 my-auto'>
<FormControl>
<Switch
className='border-solid border-primary'
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Automatic Lunch</FormLabel>
</div>
<FormDescription>
Automatically take your lunch at the time you specify.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex justify-center mt-5'> <div className='flex justify-center'>
<SubmitButton <SubmitButton disabled={loading} pendingText='Saving...'>
className='lg:w-1/3 w-2/3 text-[1.0rem]'
disabled={loading}
pendingText='Saving...'
>
Save Changes Save Changes
</SubmitButton> </SubmitButton>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</>
); );
}; };

View File

@@ -10,11 +10,9 @@ import {
Button, Button,
Card, Card,
CardContent, CardContent,
Checkbox,
Drawer, Drawer,
DrawerTrigger, DrawerTrigger,
Input, Input,
Label,
SubmitButton, SubmitButton,
Tabs, Tabs,
TabsContent, TabsContent,
@@ -51,7 +49,6 @@ export const StatusList = ({
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
const [selectAll, setSelectAll] = useState(false); const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState(''); const [statusInput, setStatusInput] = useState('');
const [persistStatus, setPersistStatus] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false); const [updatingStatus, setUpdatingStatus] = useState(false);
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set()); const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
const [previousStatuses, setPreviousStatuses] = useState(statuses); const [previousStatuses, setPreviousStatuses] = useState(statuses);
@@ -101,24 +98,11 @@ export const StatusList = ({
throw new Error('Status must be between 3 & 80 characters'); throw new Error('Status must be between 3 & 80 characters');
} }
if (selectedUserIds.length === 0 && user?.id) { if (selectedUserIds.length === 0 && user?.id) {
await bulkCreate({ await bulkCreate({ message, userIds: [user.id] });
message,
userIds: [user.id],
persistentStatus: persistStatus
});
} else { } else {
await bulkCreate({ await bulkCreate({ message, userIds: selectedUserIds });
message,
userIds: selectedUserIds,
persistentStatus: persistStatus
});
} }
toast.success('Status updated.'); toast.success('Status updated.');
toast.success('Status updated.', {
duration: 2000,
closeButton: true,
dismissible: true,
});
setSelectedUserIds([]); setSelectedUserIds([]);
setSelectAll(false); setSelectAll(false);
setStatusInput(''); setStatusInput('');
@@ -140,9 +124,9 @@ export const StatusList = ({
const containerCn = ccn({ const containerCn = ccn({
context: tvMode, context: tvMode,
className: 'mx-auto', className: 'max-w-4xl mx-auto',
on: 'px-6', on: 'px-6',
off: 'max-w-4xl px-4 sm:px-6', off: 'px-4 sm:px-6',
}); });
const tabsCn = ccn({ const tabsCn = ccn({
@@ -165,13 +149,13 @@ export const StatusList = ({
<TabsList className={tabsCn}> <TabsList className={tabsCn}>
<TabsTrigger value='status' className='py-3 sm:py-8'> <TabsTrigger value='status' className='py-3 sm:py-8'>
<div className='flex items-center gap-2 sm:gap-3'> <div className='flex items-center gap-2 sm:gap-3'>
<Activity className='text-primary sm:scale-150' /> <Activity className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
<h1 className='text-base sm:text-2xl font-bold'>Status List</h1> <h1 className='text-base sm:text-2xl font-bold'>Team Status</h1>
</div> </div>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value='history' className='py-3 sm:py-8'> <TabsTrigger value='history' className='py-3 sm:py-8'>
<div className='flex items-center gap-2 sm:gap-3'> <div className='flex items-center gap-2 sm:gap-3'>
<History className='text-primary sm:scale-150' /> <History className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
<h1 className='text-base sm:text-2xl font-bold'> <h1 className='text-base sm:text-2xl font-bold'>
Status History Status History
</h1> </h1>
@@ -201,14 +185,11 @@ export const StatusList = ({
{/* Desktop header */} {/* Desktop header */}
<div className={headerCn}> <div className={headerCn}>
<div className='flex w-full justify-between px-4'>
<div className='flex items-center gap-4 text-xs sm:text-base'> <div className='flex items-center gap-4 text-xs sm:text-base'>
<div className='flex items-center gap-2 text-muted-foreground'> <div className='flex items-center gap-2 text-muted-foreground'>
<Users className='sm:w-4 sm:h-4 w-3 h-3' /> <Users className='sm:w-4 sm:h-4 w-3 h-3' />
<span>{statuses.length} members</span> <span>{statuses.length} members</span>
</div> </div>
</div>
<div className='flex items-center gap-4 text-xs sm:text-base'>
<div className='flex items-center gap-2 text-xs'> <div className='flex items-center gap-2 text-xs'>
<Link href='/table' className='font-medium hover:underline'> <Link href='/table' className='font-medium hover:underline'>
Miss the old table? Miss the old table?
@@ -216,11 +197,10 @@ export const StatusList = ({
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Card list */} {/* Card list */}
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'> <div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
{previousStatuses.map((statusData) => { {statuses.map((statusData) => {
const { user: u, status: s } = statusData; const { user: u, status: s } = statusData;
const isSelected = selectedUserIds.includes(u.id); const isSelected = selectedUserIds.includes(u.id);
const isAnimating = animatingIds.has(u.id); const isAnimating = animatingIds.has(u.id);
@@ -232,7 +212,6 @@ export const StatusList = ({
className={` className={`
relative rounded-xl border transition-all relative rounded-xl border transition-all
${isAnimating ? 'bg-primary/5 border-primary/30' : ''} ${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
${s?.persistentStatus ? 'bg-black/10' : ''}
${ ${
isSelected isSelected
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
@@ -253,46 +232,43 @@ export const StatusList = ({
<div className='flex items-start gap-3 sm:gap-4'> <div className='flex items-start gap-3 sm:gap-4'>
{/* Avatar */} {/* Avatar */}
<div className='shrink-0'> <div className='flex-shrink-0'>
<BasedAvatar <BasedAvatar
src={u.imageUrl} src={u.imageUrl}
fullName={u.name ?? 'User'} fullName={u.name ?? 'User'}
className={` className={`
transition-all duration-300 transition-all duration-300
${tvMode ? 'w-36 h-36 text-4xl' : 'w-10 h-10 sm:w-12 sm:h-12'} ${tvMode ? 'w-18 h-18' : 'w-10 h-10 sm:w-12 sm:h-12'}
${isAnimating ? 'ring-primary/30 ring-4' : ''} ${isAnimating ? 'ring-primary/30 ring-4' : ''}
`} `}
/> />
</div> </div>
{/* Content */} {/* Content */}
<div className='flex-1 min-w-0'> <div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 sm:gap-3 mb-1'> <div className='flex items-center gap-2 sm:gap-3 mb-1'>
<h3 <h3
className={`font-semibold className={`
${tvMode ? 'text-5xl' : 'text-base sm:text-xl'} font-semibold truncate
${tvMode ? 'text-3xl' : 'text-base sm:text-xl'}
`} `}
title={u.name ?? u.email ?? 'User'} title={u.name ?? u.email ?? 'User'}
> >
{u.name ?? u.email ?? 'User'} {u.name ?? u.email ?? 'User'}
</h3> </h3>
{isUpdatedByOther && s?.updatedBy && ( {isUpdatedByOther && s?.updatedBy && (
<div <div
className='hidden sm:flex items-center gap-2 className='hidden sm:flex items-center gap-2
text-muted-foreground min-w-0' text-muted-foreground min-w-0'
> >
<span <span className='text-sm'>via</span>
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
>
via
</span>
<BasedAvatar <BasedAvatar
src={s.updatedBy.imageUrl} src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name ?? 'User'} fullName={s.updatedBy.name ?? 'User'}
className={`${tvMode ? 'w-14 h-14 text-xl' : 'w-6 h-6'}`} className='w-4 h-4'
/> />
<span <span className='text-sm truncate'>
className={`${tvMode ? 'text-4xl' : 'truncate'}`}
>
{s.updatedBy.name ?? {s.updatedBy.name ??
s.updatedBy.email ?? s.updatedBy.email ??
'another user'} 'another user'}
@@ -300,67 +276,51 @@ export const StatusList = ({
</div> </div>
)} )}
</div> </div>
<div <div
className={` className={`
mb-2 sm:mb-3 mb-2 sm:mb-3 leading-relaxed break-words
${tvMode ? 'text-6xl' : 'text-[0.95rem] sm:text-lg'} ${tvMode ? 'text-2xl' : 'text-[0.95rem] sm:text-lg'}
${s ? 'text-foreground' : 'text-muted-foreground italic'} ${
s
? 'text-foreground'
: 'text-muted-foreground italic'
}
line-clamp-2
`} `}
title={s?.message ?? undefined} title={s?.message ?? undefined}
> >
{s?.message ?? 'No status yet.'} {s?.message ?? 'No status yet.'}
</div> </div>
{/* Meta - only show here when NOT in TV mode */}
{!tvMode && ( {/* Meta */}
<div className='flex items-center text-muted-foreground gap-3 sm:gap-4'> <div
className='flex items-center gap-3 sm:gap-4
text-muted-foreground'
>
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<Clock className='w-4 h-4 sm:w-4 sm:h-4' /> <Clock className='w-4 h-4 sm:w-4 sm:h-4' />
<span className='text-sm sm:text-lg'> <span className='text-xs sm:text-sm'>
{s ? formatTime(s.updatedAt) : '--:--'} {s ? formatTime(s.updatedAt) : '--:--'}
</span> </span>
</div> </div>
<div className='flex items-center gap-1.5'> <div className='hidden xs:flex items-center gap-1.5'>
<Calendar className='w-4 h-4 sm:w-4 sm:h-4' /> <Calendar className='w-4 h-4' />
<span className='text-sm sm:text-lg'> <span className='text-xs sm:text-sm'>
{s ? formatDate(s.updatedAt) : '--/--'} {s ? formatDate(s.updatedAt) : '--/--'}
</span> </span>
</div> </div>
{s && ( {s && (
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<Activity className='w-4 h-4 sm:w-4 sm:h-4' /> <Activity className='w-4 h-4' />
<span className='text-sm sm:text-lg'> <span className='text-xs sm:text-sm'>
{getStatusAge(s.updatedAt)} {getStatusAge(s.updatedAt)}
</span> </span>
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
{/* Date/Time Column - only show when in TV mode */}
{tvMode && (
<div className='flex flex-col items-end gap-2 text-muted-foreground min-w-0'>
<div className='flex items-center gap-1.5'>
<Clock className='w-8 h-8' />
<span className='text-4xl'>
{s ? formatTime(s.updatedAt) : '--:--'}
</span>
</div>
<div className='flex items-center gap-1.5'>
<Calendar className='w-8 h-8' />
<span className='text-4xl'>
{s ? formatDate(s.updatedAt) : '--/--'}
</span>
</div>
{s && (
<div className='flex items-center gap-1.5'>
<Activity className='w-8 h-8' />
<span className='text-4xl'>
{getStatusAge(s.updatedAt)}
</span>
</div>
)}
</div>
)}
{/* Actions */} {/* Actions */}
{!tvMode && ( {!tvMode && (
<div className='flex flex-col items-end gap-2'> <div className='flex flex-col items-end gap-2'>
@@ -381,6 +341,7 @@ export const StatusList = ({
</div> </div>
)} )}
</div> </div>
{/* Mobile "via user" line */} {/* Mobile "via user" line */}
{isUpdatedByOther && s?.updatedBy && ( {isUpdatedByOther && s?.updatedBy && (
<div <div
@@ -414,10 +375,9 @@ export const StatusList = ({
> >
<CardContent className='p-6'> <CardContent className='p-6'>
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='flex gap-3 w-full justify-between'> <div className='flex items-center gap-3'>
<div className='flex gap-3 items-center'> <Zap className='w-5 h-5 text-primary' />
<Zap className='w-6 h-6 text-primary' /> <h3 className='text-lg font-semibold'>Update Status</h3>
<h3 className='text-xl font-semibold'>Update Status</h3>
{selectedUserIds.length > 0 && ( {selectedUserIds.length > 0 && (
<span <span
className='px-2 py-1 bg-primary/10 text-primary className='px-2 py-1 bg-primary/10 text-primary
@@ -427,17 +387,6 @@ export const StatusList = ({
</span> </span>
)} )}
</div> </div>
<div className='flex space-x-2 items-center'>
<Checkbox
checked={persistStatus}
className='border border-primary'
onCheckedChange={() => setPersistStatus(!persistStatus)}
/>
<Label>
Persist Status
</Label>
</div>
</div>
<div className='flex gap-3'> <div className='flex gap-3'>
<Input <Input
autoFocus autoFocus
@@ -494,7 +443,7 @@ export const StatusList = ({
<div <div
className='md:hidden fixed bottom-0 left-0 right-0 z-50 className='md:hidden fixed bottom-0 left-0 right-0 z-50
border-t bg-background/95 backdrop-blur border-t bg-background/95 backdrop-blur
supports-backdrop-filter:bg-background/60 p-3 supports-[backdrop-filter]:bg-background/60 p-3
pb-[calc(0.75rem+env(safe-area-inset-bottom))]' pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
> >
<div className='flex items-center justify-between mb-2'> <div className='flex items-center justify-between mb-2'>
@@ -507,16 +456,6 @@ export const StatusList = ({
Update your status Update your status
</span> </span>
)} )}
<div className='flex flex-row space-x-2'>
<Checkbox
className='border border-primary'
checked={persistStatus}
onCheckedChange={() => setPersistStatus(!persistStatus)}
/>
<Label className='text-xs'>
Persist Status
</Label>
</div>
<Button variant='outline' size='sm' onClick={handleSelectAll}> <Button variant='outline' size='sm' onClick={handleSelectAll}>
{selectAll ? 'Clear' : 'Select all'} {selectAll ? 'Clear' : 'Select all'}
</Button> </Button>

View File

@@ -8,6 +8,8 @@ import { useTVMode } from '@/components/providers';
import { import {
BasedAvatar, BasedAvatar,
Button, Button,
Card,
CardContent,
Drawer, Drawer,
DrawerTrigger, DrawerTrigger,
Input, Input,
@@ -15,7 +17,7 @@ import {
} from '@/components/ui'; } from '@/components/ui';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ccn, formatTime, formatDate } from '@/lib/utils'; import { ccn, formatTime, formatDate } from '@/lib/utils';
import { Clock, Calendar } from 'lucide-react'; import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
import { StatusHistory } from '@/components/layout/status'; import { StatusHistory } from '@/components/layout/status';
type StatusTableProps = { type StatusTableProps = {
@@ -118,7 +120,7 @@ export const StatusTable = ({
</div> </div>
<table className='w-full text-center rounded-md'> <table className='w-full text-center rounded-md'>
<thead> <thead>
<tr className='dark:bg-muted bg-accent/30'> <tr className='bg-muted'>
{!tvMode && ( {!tvMode && (
<th className={tCheckboxCn}> <th className={tCheckboxCn}>
<input <input
@@ -149,11 +151,7 @@ export const StatusTable = ({
<tr <tr
key={u.id} key={u.id}
className={` className={`
${ ${i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
i % 2 === 0
? 'dark:bg-muted/20 bg-muted'
: 'dark:bg-muted/80 bg-accent/50'
}
${isSelected ? 'ring-2 ring-primary' : ''} ${isSelected ? 'ring-2 ring-primary' : ''}
hover:bg-muted/75 transition-all duration-300 hover:bg-muted/75 transition-all duration-300
`} `}
@@ -173,7 +171,7 @@ export const StatusTable = ({
<BasedAvatar <BasedAvatar
src={u.imageUrl} src={u.imageUrl}
fullName={u.name} fullName={u.name}
className={tvMode ? 'w-16 h-16 text-2xl' : 'w-12 h-12'} className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/> />
<div> <div>
<p> {u.name ?? 'Technician #' + (i + 1)} </p> <p> {u.name ?? 'Technician #' + (i + 1)} </p>
@@ -182,7 +180,7 @@ export const StatusTable = ({
<BasedAvatar <BasedAvatar
src={s.updatedBy.imageUrl} src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name} fullName={s.updatedBy.name}
className='w-5 h-5 text-xs' className='w-5 h-5'
/> />
<span className={tvMode ? 'text-xl' : 'text-base'}> <span className={tvMode ? 'text-xl' : 'text-base'}>
Updated by {s.updatedBy.name} Updated by {s.updatedBy.name}

View File

@@ -1,6 +1,4 @@
export { ConvexClientProvider } from './ConvexClientProvider'; export { ConvexClientProvider } from './ConvexClientProvider';
export { LunchReminder } from './lunch-reminder';
export { NotificationsPermission } from './notification-permission';
export { export {
ThemeProvider, ThemeProvider,
ThemeToggle, ThemeToggle,

View File

@@ -1,76 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useMutation, useQuery } from 'convex/react';
import { api } from '~/convex/_generated/api';
const nextOccurrenceMs = (hhmm: string, from = new Date()): number => {
const [hStr, mStr] = hhmm.split(':');
const target = new Date(from);
target.setHours(Number(hStr), Number(mStr), 0, 0);
if (target <= from) target.setDate(target.getDate() + 1);
return target.getTime() - from.getTime();
};
export const LunchReminder = () => {
const setStatus = useMutation(api.statuses.createLunchStatus);
const timeoutRef = useRef<number | null>(null);
const user = useQuery(api.auth.getUser, {});
const lunchTime = user?.lunchTime ?? '';
const automaticLunch = user?.automaticLunch ?? false;
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (!lunchTime || automaticLunch) {
return;
}
const schedule = () => {
const ms = nextOccurrenceMs(lunchTime);
console.log('Ms = ', ms);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
void (async () => {
try {
if (
'Notification' in window &&
Notification.permission === 'granted'
) {
new Notification('Lunch time?', {
body: 'Update your status to "At lunch"?',
tag: 'tech-tracker-lunch',
});
}
} catch {}
toast('Lunch time?', {
description: 'Would you like to set your status to "At lunch"?',
action: {
label: 'Set to lunch',
onClick: () => void setStatus({}),
},
cancel: {
label: 'Not now',
onClick: () => console.log('User declined lunch suggestion'),
},
id: 'lunch-reminder',
});
schedule();
})();
}, ms);
};
schedule();
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null
}
};
}, [automaticLunch, lunchTime, setStatus]);
return null;
};

View File

@@ -1,55 +0,0 @@
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
const STORAGE_KEY = 'notif.permission.prompted.v1';
export const NotificationsPermission = () => {
useEffect(() => {
if (typeof window === 'undefined') return;
if (!('Notification' in window)) return;
// Only ask once; tweak logic to your taste.
const prompted = localStorage.getItem(STORAGE_KEY) === '1';
console.log('NotificationsPermission', {
supported: true,
permission: Notification.permission,
prompted,
});
if (prompted) return;
if (Notification.permission === 'default') {
toast('Enable system notifications?', {
id: 'enable-notifications',
description: 'Get a native notification at lunch time (optional).',
action: {
label: 'Enable',
onClick: () => {
// Must be called during the click handler.
const p = Notification.requestPermission();
p.then((perm) => {
localStorage.setItem(STORAGE_KEY, '1');
if (perm === 'granted') {
toast.success('System notifications enabled');
} else {
toast('Okay, we will use in-app toasts instead.');
}
}).catch(() => {
localStorage.setItem(STORAGE_KEY, '1');
toast('Failed to request notification permission.');
});
},
},
cancel: {
label: 'Not now',
onClick: () => {
localStorage.setItem(STORAGE_KEY, '1');
},
},
});
}
}, []);
return null;
};

View File

@@ -1,32 +0,0 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -11,7 +11,6 @@ export {
CardDescription, CardDescription,
CardContent, CardContent,
} from './card'; } from './card';
export { Checkbox } from './checkbox';
export { export {
Drawer, Drawer,
DrawerPortal, DrawerPortal,
@@ -55,12 +54,6 @@ export {
ImageCropReset, ImageCropReset,
} from './shadcn-io/image-crop'; } from './shadcn-io/image-crop';
export { Input } from './input'; export { Input } from './input';
export {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from './input-otp';
export { Label } from './label'; export { Label } from './label';
export { export {
Pagination, Pagination,
@@ -76,7 +69,6 @@ export { ScrollArea, ScrollBar } from './scroll-area';
export { Separator } from './separator'; export { Separator } from './separator';
export { StatusMessage } from './status-message'; export { StatusMessage } from './status-message';
export { SubmitButton } from './submit-button'; export { SubmitButton } from './submit-button';
export { Switch } from './switch';
export { export {
Table, Table,
TableHeader, TableHeader,

View File

@@ -1,77 +0,0 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot='input-otp-slot'
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,31 +0,0 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot='switch'
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot='switch-thumb'
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -7,26 +7,20 @@ export const env = createEnv({
.enum(['development', 'test', 'production']) .enum(['development', 'test', 'production'])
.default('development'), .default('development'),
SKIP_ENV_VALIDATION: z.boolean().default(false), SKIP_ENV_VALIDATION: z.boolean().default(false),
SITE_URL: z.url().default('http://localhost:3000'),
SENTRY_AUTH_TOKEN: z.string(), SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(true), CI: z.boolean().default(true),
}, },
client: { client: {
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'), NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
NEXT_PUBLIC_CONVEX_URL: z NEXT_PUBLIC_SENTRY_DSN: z.url(),
.url() NEXT_PUBLIC_SENTRY_URL: z.url(),
.default('https://api.dev.convex.gbrown.org'),
NEXT_PUBLIC_SENTRY_DSN: z
.url()
.default('https://96df775337cce23d925616dd5aea8857@sentry.gbrown.org/2'),
NEXT_PUBLIC_SENTRY_URL: z.url().default('https://sentry.gbrown.org'),
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'), NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string().default('techtracker-next'), NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
}, },
runtimeEnv: { runtimeEnv: {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION, SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SITE_URL: process.env.SITE_URL,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI, CI: process.env.CI,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,

View File

@@ -3,21 +3,14 @@ import * as Sentry from '@sentry/nextjs';
Sentry.init({ Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!, dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true, sendDefaultPii: true,
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1, tracesSampleRate: 1.0,
enableLogs: true, integrations: [Sentry.replayIntegration()],
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration // https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5, replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
debug: false,
}); });
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards // `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View File

@@ -1,7 +1,10 @@
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import type { Instrumentation } from 'next'; import type { Instrumentation } from 'next';
export const register = async () => await import('../sentry.server.config'); export const register = async () => {
await import('../sentry.server.config');
};
export const onRequestError: Instrumentation.onRequestError = (...args) => { export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args); Sentry.captureRequestError(...args);
}; };

View File

@@ -51,8 +51,7 @@ export const formatDate = (timestamp: Timestamp, locale = 'en-US'): string => {
const date = toDate(timestamp); const date = toDate(timestamp);
if (!date) return '--/--'; if (!date) return '--/--';
return date.toLocaleDateString(locale, { return date.toLocaleDateString(locale, {
weekday: 'long', month: 'long',
month: 'short',
day: 'numeric', day: 'numeric',
}); });
}; };

View File

@@ -8,8 +8,7 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
const isSignInPage = createRouteMatcher(['/signin']); const isSignInPage = createRouteMatcher(['/signin']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']); const isProtectedRoute = createRouteMatcher(['/', '/profile']);
export default convexAuthNextjsMiddleware( export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request); const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse; if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) { if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
@@ -18,9 +17,7 @@ export default convexAuthNextjsMiddleware(
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) { if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/signin'); return nextjsMiddlewareRedirect(request, '/signin');
} }
}, });
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
);
export const config = { export const config = {
// The following matcher runs middleware on all routes // The following matcher runs middleware on all routes

1408
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,55 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# --- Bun on Alpine for build ---
FROM oven/bun:alpine AS base FROM oven/bun:alpine AS base
# --- deps: install node_modules with Bun ---
FROM base AS deps FROM base AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY apps/next/package.json apps/next/bun.lockb* ./
RUN bun install --frozen-lockfile
# Copy package + whichever Bun lock file you have (optional)
COPY package.json bun.lockb* bun.lock* ./
COPY tsconfig.base.json ./
COPY apps/next/package.json ./apps/next/
# If bun.lockb exists, enforce frozen; otherwise install and generate it
RUN if [ -f bun.lockb ]; then \
bun install --frozen-lockfile; \
else \
bun install; \
fi
# --- builder: build Next.js with Bun ---
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.base.json /tsconfig.base.json COPY --from=deps /app/tsconfig.base.json ./tsconfig.base.json
COPY packages/backend ./packages/backend COPY apps/next ./apps/next
COPY apps/next ./ COPY packages ./packages
WORKDIR /app/apps/next
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build RUN bun run build
#FROM base AS runner # --- runner: Node on Alpine to run server.js ---
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# non-root user
RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001 RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001
#RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/apps/next/public ./public
#RUN chown nextjs:bun .next RUN mkdir .next && chown -R nextjs:nodejs .next
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next
# Copy from the correct paths (builder has apps/next at root) # Next standalone output
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder /app/apps/next/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder /app/apps/next/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/node_modules ./node_modules
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -7,18 +7,17 @@ services:
build: build:
context: ../ context: ../
dockerfile: ./docker/Dockerfile dockerfile: ./docker/Dockerfile
image: ${NEXT_CONTAINER_NAME}:alpine image: ${CONTAINER_NAME}:alpine
container_name: ${NEXT_CONTAINER_NAME} container_name: ${CONTAINER_NAME}
env_file: [.env] env_file: [.env]
hostname: ${NEXT_CONTAINER_NAME} hostname: ${CONTAINER_NAME}
domainname: ${NEXT_DOMAIN_NAME} domainname: ${DOMAIN_NAME}
networks: ['${NETWORK:-nginx-bridge}'] networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:3000'] #ports: ['${PORT}:3000']
depends_on: ['convex-backend'] depends_on: ['convex-backend']
tty: true tty: true
stdin_open: true stdin_open: true
restart: unless-stopped restart: unless-stopped
convex-backend: convex-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-00bd92723422f3bff968230c94ccdeb8c1719832} image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-00bd92723422f3bff968230c94ccdeb8c1719832}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend} container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}

View File

@@ -0,0 +1 @@
Convex.gib

View File

@@ -0,0 +1 @@
d35a9c02aeef0070fac3069d51ac4926744b251d0e6d97dc239d78bce67959b6

BIN
docker/data/db.sqlite3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Some files were not shown because too many files have changed in this diff Show More