Compare commits

..

6 Commits

79 changed files with 1010 additions and 3120 deletions

View File

@@ -38,13 +38,11 @@ You will also need docker installed on whatever host you plan to run the Convex
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 Environment variables for Next Application
```bash ```bash
cp ./apps/next/env.example ./apps/next/.env cp ./apps/next/env.example ./apps/next/.env
``` ```
Environment variables for Self Hosting Convex & Website with Docker Environment variables for Self Hosting Convex & Website with Docker
```bash ```bash
cp ./docker/env.example ./docker/.env cp ./docker/env.example ./docker/.env
``` ```
@@ -52,7 +50,6 @@ cp ./docker/env.example ./docker/.env
### Start self hosted convex & Next Web Application ### Start self hosted convex & Next Web Application
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 ./docker
sudo docker compose up -d sudo docker compose up -d

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

@@ -14,7 +14,10 @@ export const baseConfig = {
'warn', 'warn',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' }, { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
], ],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/require-await': 'off', '@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-misused-promises': [ '@typescript-eslint/no-misused-promises': [
'error', 'error',

View File

@@ -8,23 +8,20 @@
"packageManager": "bun@1.2.19", "packageManager": "bun@1.2.19",
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:tunnel": "turbo run dev:tunnel",
"build": "turbo run build", "build": "turbo run build",
"clean": "turbo run clean && rm -rf node_modules", "clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --cache" "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --cache"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.6.2", "prettier": "^3.6.2",
"turbo": "^2.5.8", "turbo": "^2.5.6",
"eslint": "^9.38.0", "eslint": "^9.35.0",
"typescript": "^5.9.3", "typescript": "^5.9.2",
"@types/node": "^20.19.23" "@types/node": "^20.19.13"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@sentry/cli", "@sentry/cli",
"@tailwindcss/oxide", "@tailwindcss/oxide",
"esbuild",
"sharp",
"unrs-resolver" "unrs-resolver"
] ]
} }

View File

@@ -0,0 +1,18 @@
import { ConvexError } from 'convex/values';
import { Password } from '@convex-dev/auth/providers/Password';
import { validatePassword } from './auth';
import type { DataModel } from './_generated/dataModel';
export default Password<DataModel>({
profile(params, ctx) {
return {
email: params.email as string,
name: params.name as string,
};
},
validatePasswordRequirements: (password: string) => {
if (!validatePassword(password)) {
throw new ConvexError('Invalid password.');
}
},
});

View File

@@ -7,8 +7,8 @@ A query function that takes two arguments looks like:
```ts ```ts
// functions.js // functions.js
import { query } from './_generated/server'; import { query } from "./_generated/server";
import { v } from 'convex/values'; import { v } from "convex/values";
export const myQueryFunction = query({ export const myQueryFunction = query({
// Validators for arguments. // Validators for arguments.
@@ -21,7 +21,7 @@ export const myQueryFunction = query({
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Read the database as many times as you need here. // Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data. // See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query('tablename').collect(); const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object. // Arguments passed from the client are properties of the args object.
console.log(args.first, args.second); console.log(args.first, args.second);
@@ -38,7 +38,7 @@ Using this query function in a React component looks like:
```ts ```ts
const data = useQuery(api.functions.myQueryFunction, { const data = useQuery(api.functions.myQueryFunction, {
first: 10, first: 10,
second: 'hello', second: "hello",
}); });
``` ```
@@ -46,8 +46,8 @@ A mutation function looks like:
```ts ```ts
// functions.js // functions.js
import { mutation } from './_generated/server'; import { mutation } from "./_generated/server";
import { v } from 'convex/values'; import { v } from "convex/values";
export const myMutationFunction = mutation({ export const myMutationFunction = mutation({
// Validators for arguments. // Validators for arguments.
@@ -62,7 +62,7 @@ export const myMutationFunction = mutation({
// Mutations can also read from the database like queries. // Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data. // See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second }; const message = { body: args.first, author: args.second };
const id = await ctx.db.insert('messages', message); const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation. // Optionally, return a value from your mutation.
return await ctx.db.get(id); return await ctx.db.get(id);
@@ -76,10 +76,10 @@ Using this mutation function in a React component looks like:
const mutation = useMutation(api.functions.myMutationFunction); const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() { function handleButtonPress() {
// fire and forget, the most common way to use mutations // fire and forget, the most common way to use mutations
mutation({ first: 'Hello!', second: 'me' }); mutation({ first: "Hello!", second: "me" });
// OR // OR
// use the result once the mutation has completed // use the result once the mutation has completed
mutation({ first: 'Hello!', second: 'me' }).then((result) => mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result), console.log(result),
); );
} }

View File

@@ -8,21 +8,17 @@
* @module * @module
*/ */
import type * as auth from "../auth.js";
import type * as crons from "../crons.js";
import type * as custom_auth_index from "../custom/auth/index.js";
import type * as custom_auth_providers_entra from "../custom/auth/providers/entra.js";
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
import type * as files from "../files.js";
import type * as http from "../http.js";
import type * as statuses from "../statuses.js";
import type { import type {
ApiFromModules, ApiFromModules,
FilterApi, FilterApi,
FunctionReference, FunctionReference,
} from "convex/server"; } from "convex/server";
import type * as CustomPassword from "../CustomPassword.js";
import type * as auth from "../auth.js";
import type * as crons from "../crons.js";
import type * as files from "../files.js";
import type * as http from "../http.js";
import type * as statuses from "../statuses.js";
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.
@@ -33,25 +29,18 @@ import type {
* ``` * ```
*/ */
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
CustomPassword: typeof CustomPassword;
auth: typeof auth; auth: typeof auth;
crons: typeof crons; crons: typeof crons;
"custom/auth/index": typeof custom_auth_index;
"custom/auth/providers/entra": typeof custom_auth_providers_entra;
"custom/auth/providers/password": typeof custom_auth_providers_password;
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
files: typeof files; files: typeof files;
http: typeof http; http: typeof http;
statuses: typeof statuses; statuses: typeof statuses;
}>; }>;
declare const fullApiWithMounts: typeof fullApi;
export declare const api: FilterApi< export declare const api: FilterApi<
typeof fullApiWithMounts, typeof fullApi,
FunctionReference<any, "public"> FunctionReference<any, "public">
>; >;
export declare const internal: FilterApi< export declare const internal: FilterApi<
typeof fullApiWithMounts, typeof fullApi,
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >;
export declare const components: {};

View File

@@ -8,7 +8,7 @@
* @module * @module
*/ */
import { anyApi, componentsGeneric } from "convex/server"; import { anyApi } from "convex/server";
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.
@@ -20,4 +20,3 @@ import { anyApi, componentsGeneric } from "convex/server";
*/ */
export const api = anyApi; export const api = anyApi;
export const internal = anyApi; export const internal = anyApi;
export const components = componentsGeneric();

View File

@@ -10,7 +10,6 @@
import { import {
ActionBuilder, ActionBuilder,
AnyComponents,
HttpActionBuilder, HttpActionBuilder,
MutationBuilder, MutationBuilder,
QueryBuilder, QueryBuilder,
@@ -19,15 +18,9 @@ import {
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter, GenericDatabaseWriter,
FunctionReference,
} from "convex/server"; } from "convex/server";
import type { DataModel } from "./dataModel.js"; import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
* *

View File

@@ -16,7 +16,6 @@ import {
internalActionGeneric, internalActionGeneric,
internalMutationGeneric, internalMutationGeneric,
internalQueryGeneric, internalQueryGeneric,
componentsGeneric,
} from "convex/server"; } from "convex/server";
/** /**

View File

@@ -7,22 +7,11 @@ import {
} from '@convex-dev/auth/server'; } from '@convex-dev/auth/server';
import { api } from './_generated/api'; import { api } from './_generated/api';
import { type Id } from './_generated/dataModel'; import { type Id } from './_generated/dataModel';
import { import { action, mutation, query } from './_generated/server';
action, import Password from './CustomPassword';
mutation,
query,
type MutationCtx,
type QueryCtx,
} from './_generated/server';
import Authentik from '@auth/core/providers/authentik';
import { Entra, Password, validatePassword } from './custom/auth';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [ providers: [Password],
Authentik({ allowDangerousEmailAccountLinking: true }),
Entra,
Password,
],
}); });
export const PASSWORD_MIN = 8; export const PASSWORD_MIN = 8;
@@ -30,120 +19,96 @@ export const PASSWORD_MAX = 100;
export const PASSWORD_REGEX = export const PASSWORD_REGEX =
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u; /^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
type RWCtx = MutationCtx | QueryCtx; export const getUser = query(async (ctx) => {
type User = { const userId = await getAuthUserId(ctx);
id: Id<'users'>; if (!userId) return null;
email: string | null;
name: string | null;
image: Id<'_storage'> | null;
lunchTime: string | null;
automaticLunch: boolean;
provider: string | null;
};
const getUserData = async (ctx: RWCtx, userId: Id<'users'>): Promise<User> => {
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const image: Id<'_storage'> | null = const image: Id<'_storage'> | null =
typeof user.image === 'string' && user.image.length > 0 typeof user.image === 'string' && user.image.length > 0
? (user.image as Id<'_storage'>) ? (user.image as Id<'_storage'>)
: null; : null;
const authAccount = await getUserAuthAccountData(ctx, userId);
return { return {
id: user._id, id: user._id,
email: user.email ?? null, email: user.email ?? null,
name: user.name ?? null, name: user.name ?? null,
image, image,
lunchTime: user.lunchTime ?? null,
automaticLunch: user.automaticLunch ?? false,
provider: authAccount?.provider ?? null,
}; };
};
const getUserAuthAccountData = async (ctx: RWCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const authAccountData = await ctx.db
.query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
.first();
return authAccountData;
};
export const getUser = query({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const userId = args.userId ?? await getAuthUserId(ctx);
if (!userId) return null;
return getUserData(ctx, userId);
},
});
export const getUserAuthAccount = query(async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
return getUserAuthAccountData(ctx, userId);
}); });
export const getAllUsers = query(async (ctx) => { export const getAllUsers = query(async (ctx) => {
const users = await ctx.db.query('users').collect(); const users = await ctx.db.query('users').collect();
return Promise.all(users.map((u) => getUserData(ctx, u._id))); return users.map((u) => ({
id: u._id,
email: u.email ?? null,
name: u.name ?? null,
image: u.image ?? null,
}));
}); });
export const getAllUserIds = query(async (ctx) => { export const getAllUserIds = query(async (ctx) => {
const users = await ctx.db.query('users').collect(); const users = await ctx.db.query('users').collect();
return users.map((u) => u._id); const userIds = users.map((u) => u._id);
return userIds;
}); });
export const updateUser = mutation({ export const updateUserName = mutation({
args: { args: {
name: v.optional(v.string()), name: v.string(),
email: v.optional(v.string()),
image: v.optional(v.id('_storage')),
lunchTime: v.optional(v.string()),
automaticLunch: v.optional(v.boolean()),
}, },
handler: async (ctx, args) => { handler: async (ctx, { name }) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.'); if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
if (args.lunchTime !== undefined && !args.lunchTime.includes(':')) { await ctx.db.patch(userId, { name });
throw new ConvexError('Lunch time is invalid.');
}
const patch: Partial<{
name: string;
email: string;
image: Id<'_storage'>;
lunchTime: string;
automaticLunch: boolean;
}> = {};
if (args.name !== undefined) patch.name = args.name;
if (args.email !== undefined) patch.email = args.email;
if (args.lunchTime !== undefined) patch.lunchTime = args.lunchTime;
if (args.automaticLunch !== undefined)
patch.automaticLunch = args.automaticLunch;
if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined;
patch.image = args.image;
if (oldImage && oldImage !== args.image) {
await ctx.storage.delete(oldImage);
}
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(userId, patch);
}
return { success: true }; return { success: true };
}, },
}); });
export const updateUserEmail = mutation({
args: {
email: v.string(),
},
handler: async (ctx, { email }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
await ctx.db.patch(userId, { email });
return { success: true };
},
});
export const updateUserImage = mutation({
args: {
storageId: v.id('_storage'),
},
handler: async (ctx, { storageId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const oldImage = user.image as Id<'_storage'> | undefined;
await ctx.db.patch(userId, { image: storageId });
if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage);
return { success: true };
},
});
export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password)
) {
return false;
}
return true;
};
export const updateUserPassword = action({ export const updateUserPassword = action({
args: { args: {
currentPassword: v.string(), currentPassword: v.string(),
@@ -152,7 +117,7 @@ export const updateUserPassword = action({
handler: async (ctx, { currentPassword, newPassword }) => { handler: async (ctx, { currentPassword, newPassword }) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.'); if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.runQuery(api.auth.getUser, { userId }); const user = await ctx.runQuery(api.auth.getUser);
if (!user?.email) throw new ConvexError('User not found.'); if (!user?.email) throw new ConvexError('User not found.');
const verified = await retrieveAccount(ctx, { const verified = await retrieveAccount(ctx, {
provider: 'password', provider: 'password',

View File

@@ -1,20 +1,12 @@
// convex/crons.ts
import { cronJobs } from 'convex/server'; import { cronJobs } from 'convex/server';
import { api } from './_generated/api'; import { api } from './_generated/api';
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
const crons = cronJobs(); const crons = cronJobs();
// Runs at 5:00 PM America/Chicago, MondayFriday.
// Convex will handle DST if your project version supports `timeZone`.
crons.cron( crons.cron(
// Run at 7:00 AM CST / 8:00 AM CDT
// Only on weekdays
'Schedule Automatic Lunches',
'0 13 * * 1-5',
api.statuses.automaticLunch,
);
crons.cron(
// Run at 4:00 PM CST / 5:00 PM CDT
// Only on weekdays
'End of shift (weekdays 5pm CT)', 'End of shift (weekdays 5pm CT)',
'0 22 * * 1-5', '0 22 * * 1-5',
api.statuses.endOfShiftUpdate, api.statuses.endOfShiftUpdate,

View File

@@ -1,3 +0,0 @@
export { Entra } from './providers/entra';
export { Password, validatePassword } from './providers/password';
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';

View File

@@ -1,31 +0,0 @@
import { type AuthProviderMaterializedConfig } from '@convex-dev/auth/server';
export const Entra: AuthProviderMaterializedConfig = {
id: 'microsoft-entra-id',
name: 'Microsoft Entra ID',
type: 'oauth',
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER!,
client: {
id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
},
authorization: {
url: process.env.AUTH_MICROSOFT_ENTRA_ID_AUTH_URL!,
params: {
scope: 'openid profile email offline_access',
response_type: 'code',
},
},
token:
'https://login.microsoftonline.com/16200986-86f1-44d2-974c-cfa99352722c/oauth2/v2.0/token',
userinfo: 'https://graph.microsoft.com/oidc/userinfo',
allowDangerousEmailAccountLinking: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
//image: profile.picture,
};
},
};

View File

@@ -1,33 +0,0 @@
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
import { DataModel } from '../../../_generated/dataModel';
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { ConvexError } from 'convex/values';
export const Password = DefaultPassword<DataModel>({
profile(params, ctx) {
return {
email: params.email as string,
name: params.name as string,
};
},
validatePasswordRequirements: (password: string) => {
if (!validatePassword(password)) {
throw new ConvexError('Invalid password.');
}
},
reset: UseSendOTPPasswordReset,
verify: UseSendOTP,
});
export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password)
) {
return false;
}
return true;
};

View File

@@ -1,90 +0,0 @@
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
import { alphabet } from 'oslo/crypto';
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
import { UseSend } from 'usesend-js';
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
return {
id: 'usesend',
type: 'email',
name: 'UseSend',
from: 'TechTracker <admin@techtracker.gbrown.org>',
maxAge: 24 * 60 * 60, // 24 hours
async generateVerificationToken() {
const random: RandomReader = {
read(bytes) {
crypto.getRandomValues(bytes);
},
};
return generateRandomString(random, alphabet('0-9'), 6);
},
async sendVerificationRequest(params) {
const { identifier: to, provider, url, theme, token } = params;
//const { host } = new URL(url);
const host = 'TechTracker';
const useSend = new UseSend(
process.env.AUTH_USESEND_API_KEY!,
'https://usesend.gbrown.org',
);
// For password reset, we want to send the code, not the magic link
const isPasswordReset =
url.includes('reset') || provider.id?.includes('reset');
const result = await useSend.emails.send({
from: provider.from!,
to: [to],
subject: isPasswordReset
? `Reset your password - ${host}`
: `Sign in to ${host}`,
text: isPasswordReset
? `Your password reset code is ${token}`
: `Your sign in code is ${token}`,
html: isPasswordReset
? `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Password Reset Request</h2>
<p>You requested a password reset. Your reset code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
</div>
`
: `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Your Sign In Code</h2>
<p>Your verification code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 24 hours.</p>
</div>
`,
});
if (result.error) {
throw new Error('UseSend error: ' + JSON.stringify(result.error));
}
},
options: config,
};
}
// Create specific instances for password reset and email verification
export const UseSendOTPPasswordReset = UseSendProvider({
id: 'usesend-otp-password-reset',
apiKey: process.env.AUTH_USESEND_API_KEY,
maxAge: 60 * 60, // 1 hour
});
export const UseSendOTP = UseSendProvider({
id: 'usesend-otp',
apiKey: process.env.AUTH_USESEND_API_KEY,
maxAge: 60 * 20, // 20 minutes
});

View File

@@ -1,5 +1,5 @@
import { defineSchema, defineTable } from 'convex/server'; import { defineSchema, defineTable } from 'convex/server';
import { v, VId } from 'convex/values'; import { v } from 'convex/values';
import { authTables } from '@convex-dev/auth/server'; import { authTables } from '@convex-dev/auth/server';
// The schema is normally optional, but Convex Auth // The schema is normally optional, but Convex Auth
@@ -12,8 +12,6 @@ export default defineSchema({
image: v.optional(v.string()), image: v.optional(v.string()),
email: v.optional(v.string()), email: v.optional(v.string()),
currentStatusId: v.optional(v.id('statuses')), currentStatusId: v.optional(v.id('statuses')),
lunchTime: v.optional(v.string()),
automaticLunch: v.optional(v.boolean()),
emailVerificationTime: v.optional(v.number()), emailVerificationTime: v.optional(v.number()),
phone: v.optional(v.string()), phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()), phoneVerificationTime: v.optional(v.number()),
@@ -26,7 +24,6 @@ export default defineSchema({
message: v.string(), message: v.string(),
updatedAt: v.number(), updatedAt: v.number(),
updatedBy: v.optional(v.id('users')), updatedBy: v.optional(v.id('users')),
persistentStatus: v.optional(v.boolean()),
}) })
.index('by_user', ['userId']) .index('by_user', ['userId'])
.index('by_user_updatedAt', ['userId', 'updatedAt']), .index('by_user_updatedAt', ['userId', 'updatedAt']),

View File

@@ -4,6 +4,7 @@ import {
type MutationCtx, type MutationCtx,
type QueryCtx, type QueryCtx,
action, action,
internalMutation,
mutation, mutation,
query, query,
} from './_generated/server'; } from './_generated/server';
@@ -12,6 +13,7 @@ import type { Doc, Id } from './_generated/dataModel';
import { paginationOptsValidator } from 'convex/server'; import { paginationOptsValidator } from 'convex/server';
type RWCtx = MutationCtx | QueryCtx; type RWCtx = MutationCtx | QueryCtx;
type StatusRow = { type StatusRow = {
user: { user: {
id: Id<'users'>; id: Id<'users'>;
@@ -24,7 +26,6 @@ type StatusRow = {
message: string; message: string;
updatedAt: number; updatedAt: number;
updatedBy: StatusRow['user'] | null; updatedBy: StatusRow['user'] | null;
persistentStatus: boolean;
} | null; } | null;
}; };
@@ -34,73 +35,72 @@ type Paginated<T> = {
continueCursor: string | null; continueCursor: string | null;
}; };
// CHANGED: typed helpers
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => { const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
return user; return user;
}; };
const getName = (u: Doc<'users'>): string | null => const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
'name' in u && typeof u.name === 'string' ? u.name : null; const [latest] = await ctx.db
.query('statuses')
const getEmail = (u: Doc<'users'>): string | null => .withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
'email' in u && typeof u.email === 'string' ? u.email : null; .order('desc')
.take(1);
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => { return latest as Doc<'statuses'> | null;
if (!('image' in u)) return null;
const img = (u as { image?: unknown }).image as string | undefined;
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
}; };
/**
* Create a new status for a single user.
* - Defaults userId to the caller.
* - updatedBy defaults to the caller.
* - Updates the user's currentStatusId pointer.
*/
export const create = mutation({ export const create = mutation({
args: { args: {
message: v.string(), message: v.string(),
userId: v.optional(v.id('users')), userId: v.optional(v.id('users')),
updatedBy: v.optional(v.id('users')), updatedBy: v.optional(v.id('users')),
persistentStatus: v.optional(v.boolean()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const authUserId: Id<'users'> | null = await getAuthUserId(ctx); const authUserId = await getAuthUserId(ctx);
if (!args.userId && !authUserId) { if (!authUserId) throw new ConvexError('Not authenticated.');
throw new ConvexError('Not authenticated.');
} const userId = args.userId ?? authUserId;
await ensureUser(ctx, userId);
const updatedBy = args.updatedBy ?? authUserId;
await ensureUser(ctx, updatedBy);
const message = args.message.trim(); const message = args.message.trim();
if (message.length === 0) { if (message.length === 0) {
throw new ConvexError('Message cannot be empty.'); throw new ConvexError('Message cannot be empty.');
} }
const userId = args.userId ?? authUserId!;
const updatedBy = args.updatedBy ?? authUserId; const statusId = await ctx.db.insert('statuses', {
const persistentStatus = args.persistentStatus ?? false;
await ensureUser(ctx, userId);
let statusId: Id<'statuses'>;
if (updatedBy) {
ensureUser(ctx, updatedBy);
statusId = await ctx.db.insert('statuses', {
message, message,
userId, userId,
updatedBy, updatedBy,
updatedAt: Date.now(), updatedAt: Date.now(),
persistentStatus,
}); });
} else {
statusId = await ctx.db.insert('statuses', {
message,
userId,
updatedAt: Date.now(),
persistentStatus,
});
}
await ctx.db.patch(userId, { currentStatusId: statusId }); await ctx.db.patch(userId, { currentStatusId: statusId });
return { statusId }; return { statusId };
}, },
}); });
/**
* Bulk create the same status for many users.
* - updatedBy defaults to the caller.
* - Updates each user's currentStatusId pointer.
*/
export const bulkCreate = mutation({ export const bulkCreate = mutation({
args: { args: {
message: v.string(), message: v.string(),
userIds: v.array(v.id('users')), userIds: v.array(v.id('users')),
updatedBy: v.optional(v.id('users')), updatedBy: v.optional(v.id('users')),
persistentStatus: v.optional(v.boolean()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx); const authUserId = await getAuthUserId(ctx);
@@ -109,7 +109,6 @@ export const bulkCreate = mutation({
if (args.userIds.length === 0) return { statusIds: [] }; if (args.userIds.length === 0) return { statusIds: [] };
const updatedBy = args.updatedBy ?? authUserId; const updatedBy = args.updatedBy ?? authUserId;
const persistentStatus = args.persistentStatus ?? false;
await ensureUser(ctx, updatedBy); await ensureUser(ctx, updatedBy);
const message = args.message.trim(); const message = args.message.trim();
@@ -120,130 +119,91 @@ export const bulkCreate = mutation({
const statusIds: Id<'statuses'>[] = []; const statusIds: Id<'statuses'>[] = [];
const now = Date.now(); const now = Date.now();
// Sequential to keep load predictable; switch to Promise.all
// if your ownerIds lists are small and bounded.
for (const userId of args.userIds) { for (const userId of args.userIds) {
await ensureUser(ctx, userId); await ensureUser(ctx, userId);
const statusId = await ctx.db.insert('statuses', { const statusId = await ctx.db.insert('statuses', {
message, message,
userId, userId,
updatedBy, updatedBy,
updatedAt: now, updatedAt: now,
persistentStatus, });
await ctx.db.patch(userId, { currentStatusId: statusId });
statusIds.push(statusId);
}
return { statusIds };
},
});
/**
* Update all statuses for all users.
*/
export const updateAllStatuses = mutation({
args: { message: v.string() },
handler: async (ctx, args) => {
const userIds = await ctx.runQuery(api.auth.getAllUserIds);
const updatedAt = Date.now();
const statusIds: Id<'statuses'>[] = [];
for (const userId of userIds) {
await ensureUser(ctx, userId);
const statusId = await ctx.db.insert('statuses', {
message: args.message,
userId,
updatedAt,
}); });
await ctx.db.patch(userId, { currentStatusId: statusId }); await ctx.db.patch(userId, { currentStatusId: statusId });
statusIds.push(statusId); statusIds.push(statusId);
} }
return { statusIds }; return { statusIds };
}, },
}); });
export const updateAllStatuses = mutation({ /**
args: { * Current status for a specific user.
message: v.string(), * - Uses users.currentStatusId if present,
persistentStatus: v.optional(v.boolean()) * otherwise falls back to latest by index.
}, */
handler: async (ctx, args) => {
const users = await ctx.db.query('users').collect();
const message = args.message.trim();
const persistentStatus = args.persistentStatus ?? false;
if (message.length === 0) {
throw new ConvexError('Message cannot be empty.');
}
const statusIds: Id<'statuses'>[] = [];
const now = Date.now();
for (const user of users) {
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
userId: user._id,
});
if (!curStatus?.persistentStatus) {
const statusId = await ctx.db.insert('statuses', {
message,
userId: user._id,
updatedAt: now,
persistentStatus,
});
await ctx.db.patch(user._id, { currentStatusId: statusId });
statusIds.push(statusId);
}
}
return { statusIds };
},
});
export const createLunchStatus = mutation({
args: { userId: v.optional(v.id('users'))},
handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx);
const lunchUserId = args.userId ?? authUserId
if (!lunchUserId) throw new ConvexError('Not authenticated.');
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
userId: lunchUserId,
});
if (curStatus?.persistentStatus) {
return { success: false, error: 'Current status is persistent.'};
}
await ctx.runMutation(api.statuses.create, {
message: 'At lunch',
userId: lunchUserId,
});
const oneHour = 60 * 60 * 1000;
await ctx.scheduler.runAfter(oneHour, api.statuses.backFromLunchStatus, {
userId: lunchUserId,
});
return { success: true };
},
});
export const backFromLunchStatus = mutation({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx);
const lunchUserId = args.userId ?? authUserId
if (!lunchUserId) throw new ConvexError('Not authenticated.');
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
userId: lunchUserId,
});
if (curStatus?.persistentStatus) {
return { success: false, error: 'Current status is persistent.'};
}
const user = await ensureUser(ctx, lunchUserId);
if (!user.currentStatusId) throw new ConvexError('User has no current status.');
const currentStatus = await ctx.db.get(user.currentStatusId);
if (currentStatus?.message === 'At lunch') {
await ctx.runMutation(api.statuses.create, {
message: 'At desk',
userId: lunchUserId,
});
}
},
});
export const getCurrentForUser = query({ export const getCurrentForUser = query({
args: { userId: v.id('users') }, args: { userId: v.id('users') },
handler: async (ctx, { userId }) => { handler: async (ctx, { userId }) => {
const user = await ensureUser(ctx, userId); const user = await ensureUser(ctx, userId);
if (user.currentStatusId) { if (user.currentStatusId) {
const status = await ctx.db.get(user.currentStatusId); const status = await ctx.db.get(user.currentStatusId);
if (status) return status; if (status) return status;
} }
const [latest] = await ctx.db
.query('statuses')
.withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
.order('desc')
.take(1);
return latest ?? null; return await latestStatusForOwner(ctx, userId);
}, },
}); });
const getName = (u: Doc<'users'>): string | null =>
'name' in u && typeof u.name === 'string' ? u.name : null;
const getEmail = (u: Doc<'users'>): string | null =>
'email' in u && typeof u.email === 'string' ? u.email : null;
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => {
if (!('image' in u)) return null;
const img = (u as { image?: unknown }).image as string | undefined;
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
};
/**
* Current statuses for all users.
* - Reads each user's currentStatusId pointer.
* - Falls back to latest-by-index if pointer is missing.
*/
export const getCurrentForAll = query({ export const getCurrentForAll = query({
args: {}, args: {},
handler: async (ctx): Promise<StatusRow[]> => { handler: async (ctx): Promise<StatusRow[]> => {
const users = await ctx.db.query('users').collect(); const users = await ctx.db.query('users').collect();
return await Promise.all( return await Promise.all(
users.map(async (u) => { users.map(async (u) => {
// Resolve user's current or latest status
let curStatus: Doc<'statuses'> | null = null; let curStatus: Doc<'statuses'> | null = null;
if ('currentStatusId' in u && u.currentStatusId) { if ('currentStatusId' in u && u.currentStatusId) {
curStatus = await ctx.db.get(u.currentStatusId); curStatus = await ctx.db.get(u.currentStatusId);
@@ -256,14 +216,18 @@ export const getCurrentForAll = query({
.take(1); .take(1);
curStatus = latest ?? null; curStatus = latest ?? null;
} }
// User display + URL
const userImageId = getImageId(u); const userImageId = getImageId(u);
const userImageUrl = userImageId const userImageUrl = userImageId
? await ctx.storage.getUrl(userImageId) ? await ctx.storage.getUrl(userImageId)
: null; : null;
// Updated by (if different) + URL
let updatedByUser: StatusRow['user'] | null = null; let updatedByUser: StatusRow['user'] | null = null;
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) { if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
const updater = await ctx.db.get(curStatus.updatedBy); const updater = await ctx.db.get(curStatus.updatedBy);
if (updater) { if (!updater) throw new ConvexError('Updater not found.');
const updaterImageId = getImageId(updater); const updaterImageId = getImageId(updater);
const updaterImageUrl = updaterImageId const updaterImageUrl = updaterImageId
? await ctx.storage.getUrl(updaterImageId) ? await ctx.storage.getUrl(updaterImageId)
@@ -275,16 +239,16 @@ export const getCurrentForAll = query({
imageUrl: updaterImageUrl, imageUrl: updaterImageUrl,
}; };
} }
}
const status: StatusRow['status'] = curStatus const status: StatusRow['status'] = curStatus
? { ? {
id: curStatus._id, id: curStatus._id,
message: curStatus.message, message: curStatus.message,
updatedAt: curStatus.updatedAt, updatedAt: curStatus.updatedAt,
updatedBy: updatedByUser, updatedBy: updatedByUser,
persistentStatus: curStatus.persistentStatus ?? false,
} }
: null; : null;
return { return {
user: { user: {
id: u._id, id: u._id,
@@ -299,7 +263,9 @@ export const getCurrentForAll = query({
}, },
}); });
// Paginated history /**
* Paginated history for all users or for a specific user.
*/
export const listHistory = query({ export const listHistory = query({
args: { args: {
userId: v.optional(v.id('users')), userId: v.optional(v.id('users')),
@@ -309,6 +275,7 @@ export const listHistory = query({
ctx, ctx,
{ userId, paginationOpts }, { userId, paginationOpts },
): Promise<Paginated<StatusRow>> => { ): Promise<Paginated<StatusRow>> => {
// Query statuses newest-first, optionally filtered by user
const result = userId const result = userId
? await ctx.db ? await ctx.db
.query('statuses') .query('statuses')
@@ -316,15 +283,21 @@ export const listHistory = query({
.order('desc') .order('desc')
.paginate(paginationOpts) .paginate(paginationOpts)
: await ctx.db.query('statuses').order('desc').paginate(paginationOpts); : await ctx.db.query('statuses').order('desc').paginate(paginationOpts);
// Cache user display objects to avoid refetching repeatedly
const displayCache = new Map<string, StatusRow['user']>(); const displayCache = new Map<string, StatusRow['user']>();
const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => { const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
const key = uid as unknown as string; const key = uid as unknown as string;
const cached = displayCache.get(key); const cached = displayCache.get(key);
if (cached) return cached; if (cached) return cached;
const user = await ctx.db.get(uid); const user = await ctx.db.get(uid);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const imgId = getImageId(user); const imgId = getImageId(user);
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null; const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
const display: StatusRow['user'] = { const display: StatusRow['user'] = {
id: user._id, id: user._id,
email: getEmail(user), email: getEmail(user),
@@ -334,6 +307,7 @@ export const listHistory = query({
displayCache.set(key, display); displayCache.set(key, display);
return display; return display;
}; };
const statuses: StatusRow[] = []; const statuses: StatusRow[] = [];
for (const s of result.page) { for (const s of result.page) {
const owner = await getDisplay(s.userId); const owner = await getDisplay(s.userId);
@@ -341,6 +315,7 @@ export const listHistory = query({
s.updatedBy && s.updatedBy !== s.userId s.updatedBy && s.updatedBy !== s.userId
? await getDisplay(s.updatedBy) ? await getDisplay(s.updatedBy)
: null; : null;
statuses.push({ statuses.push({
user: owner, user: owner,
status: { status: {
@@ -348,12 +323,15 @@ export const listHistory = query({
message: s.message, message: s.message,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
updatedBy, updatedBy,
persistentStatus: s.persistentStatus ?? false,
}, },
}); });
} }
const page = statuses.sort(
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
);
return { return {
page: statuses, page,
isDone: result.isDone, isDone: result.isDone,
continueCursor: result.continueCursor, continueCursor: result.continueCursor,
}; };
@@ -367,49 +345,17 @@ export const endOfShiftUpdate = action({
timeZone: 'America/Chicago', timeZone: 'America/Chicago',
}), }),
); );
const day = now.getDay(), const day = now.getDay();
hour = now.getHours(), const hour = now.getHours();
minute = now.getMinutes(); const minute = now.getMinutes();
if (day == 0 || day === 6) return; if (day == 0 || day === 6) return;
const message = day === 5 ? 'Enjoying the weekend' : 'End of shift'; if (hour === 5) {
if (hour === 17) { await ctx.runMutation(api.statuses.updateAllStatuses, {
await ctx.runMutation(api.statuses.updateAllStatuses, { message }); message: 'End of shift',
} else if (hour === 16) { });
} else if (hour === 4) {
const ms = ((60 - minute) % 60) * 60 * 1000; const ms = ((60 - minute) % 60) * 60 * 1000;
await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate); await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate);
} else return; } else return;
}, },
}); });
export const automaticLunch = action({
handler: async (ctx) => {
const now = new Date(
new Date().toLocaleString('en-US', {
timeZone: 'America/Chicago',
}),
);
const users = await ctx.runQuery(api.auth.getAllUsers);
await Promise.all(
users.map(async (user) => {
if (user.automaticLunch && user.lunchTime) {
const [hours, minutes] = user.lunchTime.split(':').map(Number);
const userLunchTime = new Date(now);
userLunchTime.setHours(hours, minutes, 0, 0);
const diffInMs = userLunchTime.getTime() - now.getTime();
// Only schedule if lunch is in the future today
if (diffInMs > 0) {
await ctx.scheduler.runAfter(
diffInMs,
api.statuses.createLunchStatus,
{ userId: user.id },
);
} else {
console.warn(
`Skipped ${user.name} - lunch time ${user.lunchTime} already passed.`
);
}
}
})
);
},
});

View File

@@ -1,19 +0,0 @@
# Convex
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
SETUP_SCRIPT_RAN=
CONVEX_URL=
SITE_URL=
AUTH_URL=
CONVEX_SITE_URL=
# Authentik Environment Variables
AUTH_AUTHENTIK_ID=
AUTH_AUTHENTIK_SECRET=
AUTH_AUTHENTIK_ISSUER=
# Microsoft Entra ID
AUTH_MICROSOFT_ENTRA_ID_ID=
AUTH_MICROSOFT_ENTRA_ID_SECRET=
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
AUTH_MICROSOFT_ENTRA_ID_AUTH_URL=
# UseSend
AUTH_USESEND_API_KEY=

View File

@@ -4,23 +4,15 @@
"description": "Convex Backend for Tech Tracker", "description": "Convex Backend for Tech Tracker",
"scripts": { "scripts": {
"dev": "convex dev", "dev": "convex dev",
"dev:tunnel": "convex dev",
"predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard", "predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard",
"setup": "convex dev --until-success" "setup": "convex dev --until-success"
}, },
"author": "Gib", "author": "Gib",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@oslojs/crypto": "^1.0.1", "convex": "^1.27.0"
"@react-email/components": "0.5.4",
"@react-email/render": "^1.4.0",
"convex": "^1.28.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"usesend-js": "^1.5.6"
}, },
"devDependencies": { "devDependencies": {
"react-email": "4.2.11",
"typescript": "5.9.2" "typescript": "5.9.2"
} }
} }

View File

@@ -11,10 +11,6 @@
"cache": false, "cache": false,
"persistent": true "persistent": true
}, },
"dev:tunnel": {
"cache": false,
"persistent": true
},
"lint": {}, "lint": {},
"clean": { "clean": {
"cache": false "cache": false