Compare commits

..

4 Commits

82 changed files with 978 additions and 3297 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`.
Environment variables for Next Application
```bash
cp ./apps/next/env.example ./apps/next/.env
```
Environment variables for Self Hosting Convex & Website with Docker
```bash
cp ./docker/env.example ./docker/.env
```
@@ -52,7 +50,6 @@ cp ./docker/env.example ./docker/.env
### 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)
```bash
cd ./docker
sudo docker compose up -d
@@ -63,9 +60,9 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
Run
```bash
```bash
bun dev
```
```
### Fin

View File

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

View File

@@ -1,45 +1,25 @@
{
"expo": {
"name": "Tech Tracker",
"owner": "gib",
"name": "techtracker-expo",
"slug": "techtracker-expo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "org.gbrown.techtrackerexpo",
"scheme": "techtrackerexpo",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2e2f3d"
},
"newArchEnabled": true,
"ios": {
"usesAppleSignIn": 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."
}
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#2e2f3d"
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.INTERNET"
],
"package": "com.gbrown.techtracker"
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
@@ -47,7 +27,6 @@
},
"plugins": [
"expo-router",
"expo-apple-authentication",
[
"expo-splash-screen",
{
@@ -59,27 +38,6 @@
"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": {

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

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,24 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
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 = {
anchor: '(tabs)',
};
const RootLayout = () => {
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<ConvexProvider client={convex}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen
name='modal'
options={{ presentation: 'modal', title: 'Modal' }}
/>
</Stack>
<StatusBar style='auto' />
</ThemeProvider>
</ConvexProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
};
export default Sentry.wrap(RootLayout);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,16 +7,8 @@ export type ThemedViewProps = ViewProps & {
darkColor?: string;
};
export function ThemedView({
style,
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
const backgroundColor = useThemeColor(
{ light: lightColor, dark: darkColor },
'background',
);
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
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 { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({
children,
title,
}: PropsWithChildren & { title: string }) {
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
@@ -19,17 +16,16 @@ export function Collapsible({
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}
>
activeOpacity={0.8}>
<IconSymbol
name='chevron.right'
name="chevron.right"
size={18}
weight='medium'
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type='defaultSemiBold'>{title}</ThemedText>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>

View File

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

View File

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

View File

@@ -47,8 +47,7 @@ export const Fonts = Platform.select({
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded:
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
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(
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 colorFromProps = props[theme];

View File

@@ -3,13 +3,14 @@
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"jsx": "react-jsx",
"esModuleInterop": true,
"paths": {
"assets/*": ["./assets/*"],
"@/*": ["./src/*"],
"~/*": ["../../packages/backend/*"]
"@/*": ["./src/*"]
}
},
"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:
package-lock.json
# Sentry Config File
.env.sentry-build-plugin

View File

@@ -1,19 +1,18 @@
### Server Variables ###
# Next
NODE_ENV=
SKIP_ENV_VALIDATION=
SITE_URL=
# Convex
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
NEXT_PUBLIC_CONVEX_URL=
SETUP_SCRIPT_RAN=
# Sentry
SENTRY_AUTH_TOKEN=
CI=
### Client Variables ###
# Next
NEXT_PUBLIC_SITE_URL=
# Convex
NEXT_PUBLIC_CONVEX_URL=
# Sentry
# Next # Default Values:
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
# Sentry # Default Values
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL=
NEXT_PUBLIC_SENTRY_ORG=
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 { withPlausibleProxy } from 'next-plausible';
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: env.NEXT_PUBLIC_SENTRY_ORG,
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
authToken: env.SENTRY_AUTH_TOKEN,
org: 'gib',
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !env.CI,
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)

View File

@@ -5,7 +5,6 @@
"type": "module",
"scripts": {
"dev": "next dev --turbo",
"dev:tunnel": "next dev --turbo",
"dev:slow": "next dev",
"build": "next build",
"start": "next start",
@@ -14,9 +13,8 @@
},
"dependencies": {
"@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-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
@@ -24,41 +22,39 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@sentry/nextjs": "^10.22.0",
"@sentry/nextjs": "^10.11.0",
"@t3-oss/env-nextjs": "^0.13.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.28.0",
"convex": "^1.27.0",
"eslint-plugin-prettier": "^5.5.4",
"input-otp": "^1.4.2",
"lucide-react": "^0.542.0",
"next": "^15.5.6",
"next": "^15.5.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.65.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-image-crop": "^11.0.10",
"require-in-the-middle": "^7.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.46.2",
"typescript-eslint": "^8.43.0",
"vaul": "^1.1.2",
"zod": "^4.1.12"
"zod": "^4.1.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@types/node": "^20.19.23",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^20.19.13",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"dotenv": "^16.6.1",
"eslint-config-next": "^15.5.6",
"eslint-config-next": "^15.5.3",
"npm-run-all": "^4.1.5",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0"
"tailwindcss": "^4.1.13",
"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 './src/env.js';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
enableLogs: true,
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} />
<Separator />
<UserInfoForm preloadedUser={preloadedUser} />
<ResetPasswordForm preloadedUser={preloadedUser} />
<Separator />
<ResetPasswordForm />
<Separator />
<SignOutForm />
</Card>

View File

@@ -12,17 +12,11 @@ import {
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
Separator,
SubmitButton,
Tabs,
TabsContent,
@@ -30,10 +24,6 @@ import {
TabsTrigger,
} from '@/components/ui';
import { toast } from 'sonner';
import {
GibsAuthSignInButton,
MicrosoftSignInButton,
} from '@/components/layout/auth/buttons';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
const signInFormSchema = z.object({
@@ -50,7 +40,7 @@ const signUpFormSchema = z
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.email({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z
@@ -85,17 +75,9 @@ const signUpFormSchema = z
path: ['confirmPassword'],
});
const verifyEmailFormSchema = z.object({
code: z.string({ message: 'Invalid code.' }),
});
const SignIn = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
'signIn',
);
const [email, setEmail] = useState<string>('');
const [code, setCode] = useState<string>('');
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
const [loading, setLoading] = useState(false);
const router = useRouter();
@@ -108,17 +90,12 @@ const SignIn = () => {
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email,
email: '',
password: '',
confirmPassword: '',
},
});
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
resolver: zodResolver(verifyEmailFormSchema),
defaultValues: { code },
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
@@ -126,12 +103,13 @@ const SignIn = () => {
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
await signIn('password', formData);
signInForm.reset();
router.push('/');
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
@@ -146,107 +124,17 @@ const SignIn = () => {
try {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('email-verification');
});
await signIn('password', formData);
signUpForm.reset();
router.push('/');
} catch (error) {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
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 (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
@@ -323,28 +211,12 @@ const SignIn = () => {
<SubmitButton
disabled={loading}
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
</SubmitButton>
</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>
</Card>
</TabsContent>
@@ -437,25 +309,12 @@ const SignIn = () => {
<SubmitButton
disabled={loading}
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
</SubmitButton>
</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>
</Card>
</TabsContent>

View File

@@ -38,36 +38,38 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
Sentry.captureException(error);
}, [error]);
return (
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
<ConvexClientProvider>
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ConvexClientProvider>
<TVModeProvider>
<Header />
<main className='min-h-[90vh] flex flex-col items-center'>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
</main>
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<TVModeProvider>
<Header />
<main className='min-h-[90vh] flex flex-col items-center'>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
</main>
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexClientProvider>
);
};
export default GlobalError;

View File

@@ -4,8 +4,6 @@ import '@/styles/globals.css';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import {
ConvexClientProvider,
LunchReminder,
NotificationsPermission,
ThemeProvider,
TVModeProvider,
} from '@/components/providers';
@@ -24,11 +22,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = generateMetadata();
const RootLayout = async ({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
}>) {
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
@@ -50,8 +48,6 @@ const RootLayout = async ({
<Header />
{children}
<Toaster />
<NotificationsPermission />
<LunchReminder />
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
@@ -60,5 +56,4 @@ const RootLayout = async ({
</PlausibleProvider>
</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';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react';
import {
type Preloaded,
@@ -9,14 +10,13 @@ import {
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import {
Avatar,
AvatarImage,
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
ImageCropReset,
Input,
} from '@/components/ui';
import { toast } from 'sonner';
@@ -48,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser);
const updateUserImage = useMutation(api.auth.updateUserImage);
const currentImageUrl = useQuery(
api.files.getImageUrl,
@@ -97,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
storageId: Id<'_storage'>;
};
await updateUser({ image: uploadResponse.storageId });
await updateUserImage({ storageId: uploadResponse.storageId });
toast.success('Profile picture updated.');
handleReset();
@@ -121,7 +121,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
<BasedAvatar
src={currentImageUrl ?? undefined}
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 }}
/>
<div
@@ -172,6 +173,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
<ImageCropContent className='max-w-sm' />
<div className='flex items-center gap-2'>
<ImageCropApply />
<ImageCropReset />
<Button
onClick={handleReset}
size='icon'
@@ -188,14 +190,19 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
{/* Cropped preview + actions */}
{croppedImage && (
<div className='flex flex-col items-center gap-3'>
<Avatar className='h-42 w-42'>
<AvatarImage alt='Cropped preview' src={croppedImage} />
</Avatar>
<div className='flex items-center gap-1'>
<Image
alt='Cropped preview'
className='overflow-hidden rounded-full'
height={128}
src={croppedImage}
unoptimized
width={128}
/>
<div className='flex items-center gap-2'>
<Button
onClick={handleSave}
disabled={isUploading}
className='px-4'
className='px-6'
>
{isUploading ? (
<span className='inline-flex items-center gap-2'>
@@ -210,10 +217,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={handleReset}
size='icon'
type='button'
className='dark:bg-red-500/30 bg-red-400/80
hover:dark:text-red-300/60 hover:text-red-800/80
hover:dark:bg-accent'
variant='secondary'
variant='ghost'
>
<XIcon className='size-4' />
</Button>

View File

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

View File

@@ -1,5 +1,5 @@
'use client';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { z } from 'zod';
@@ -7,9 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
@@ -19,7 +16,6 @@ import {
FormMessage,
Input,
SubmitButton,
Switch,
} from '@/components/ui';
import { toast } from 'sonner';
@@ -36,11 +32,6 @@ const formSchema = z.object({
email: z.email({
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 = {
@@ -51,47 +42,28 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
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 updateUserName = useMutation(api.auth.updateUserName);
const updateUserEmail = useMutation(api.auth.updateUserEmail);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: initialValues,
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = [];
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const lunchTime = values.lunchTime.trim();
const automaticLunch = values.automaticLunch;
const patch: Partial<{
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;
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
if (ops.length === 0) return;
setLoading(true);
try {
await updateUser(patch);
form.reset(patch);
await Promise.all(ops);
form.reset({ name, email });
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
@@ -102,107 +74,48 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
};
return (
<>
<CardHeader>
<CardTitle className='text-2xl'>Account Information</CardTitle>
<CardDescription>Update your account information here.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
disabled={user?.provider !== 'password'}
/>
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</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>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-center mt-5'>
<SubmitButton
className='lg:w-1/3 w-2/3 text-[1.0rem]'
disabled={loading}
pendingText='Saving...'
>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
<div className='flex justify-center'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
);
};

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
export { ConvexClientProvider } from './ConvexClientProvider';
export { LunchReminder } from './lunch-reminder';
export { NotificationsPermission } from './notification-permission';
export {
ThemeProvider,
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,
CardContent,
} from './card';
export { Checkbox } from './checkbox';
export {
Drawer,
DrawerPortal,
@@ -55,12 +54,6 @@ export {
ImageCropReset,
} from './shadcn-io/image-crop';
export { Input } from './input';
export {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from './input-otp';
export { Label } from './label';
export {
Pagination,
@@ -76,7 +69,6 @@ export { ScrollArea, ScrollBar } from './scroll-area';
export { Separator } from './separator';
export { StatusMessage } from './status-message';
export { SubmitButton } from './submit-button';
export { Switch } from './switch';
export {
Table,
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'])
.default('development'),
SKIP_ENV_VALIDATION: z.boolean().default(false),
SITE_URL: z.url().default('http://localhost:3000'),
SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(true),
},
client: {
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
NEXT_PUBLIC_CONVEX_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_DSN: z.url(),
NEXT_PUBLIC_SENTRY_URL: z.url(),
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: {
NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SITE_URL: process.env.SITE_URL,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,

View File

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

View File

@@ -1,7 +1,10 @@
import * as Sentry from '@sentry/nextjs';
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) => {
Sentry.captureRequestError(...args);
};

View File

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

View File

@@ -8,19 +8,16 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
const isSignInPage = createRouteMatcher(['/signin']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/signin');
}
},
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
);
export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/signin');
}
});
export const config = {
// 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 +0,0 @@
# syntax=docker/dockerfile:1
FROM oven/bun:alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY apps/next/package.json apps/next/bun.lockb* ./
RUN bun install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.base.json /tsconfig.base.json
COPY packages/backend ./packages/backend
COPY apps/next ./
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build
#FROM base AS runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001
#RUN adduser --system --uid 1001 nextjs
#RUN chown nextjs:bun .next
# Copy from the correct paths (builder has apps/next at root)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,70 +0,0 @@
networks:
nginx-bridge: # You need to change this line to your defined network is as well
external: true
services:
techtracker-next:
build:
context: ../
dockerfile: ./docker/Dockerfile
image: ${NEXT_CONTAINER_NAME}:alpine
container_name: ${NEXT_CONTAINER_NAME}
env_file: [.env]
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN_NAME}
networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:3000']
depends_on: ['convex-backend']
tty: true
stdin_open: true
restart: unless-stopped
convex-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-00bd92723422f3bff968230c94ccdeb8c1719832}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}
hostname: ${BACKEND_CONTAINER_NAME:-convex-backend}
domainname: ${BACKEND_DOMAIN_NAME:-convex.gbrown.org}
networks: ['${NETWORK:-nginx-bridge}']
#user: '1000:1000'
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data]
labels: ['com.centurylinklabs.watchtower.enable=true']
env_file: ['.env']
environment:
- INSTANCE_NAME
- INSTANCE_SECRET
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-}
#- DATABASE_URL=${DATABASE_URL:-}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
convex-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-33cef775a8a6228cbacee4a09ac2c4073d62ed13}
container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
domainname: ${DASHBOARD_DOMAIN_NAME:-dashboard.${BACKEND_DOMAIN_NAME:-convex.gbrown.org}}
networks: ['${NETWORK:-nginx-bridge}']
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
env_file: [.env]
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
depends_on:
convex-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT

View File

@@ -1,35 +0,0 @@
# Next Envrionment Variables
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=techtracker-next
NEXT_DOMAIN_NAME=techtracker.gbrown.org
# Port is disabled by default as suggested
# config is to have reverse proxy on the same
# network so you can just forward to the
# port on the internal network.
NEXT_PORT=3000
# Convex Environment Variables
BACKEND_TAG=00bd92723422f3bff968230c94ccdeb8c1719832
BACKEND_CONTAINER_NAME=tt-convex-backend
BACKEND_DOMAIN_NAME=convex.gbrown.org
#BACKEND_PORT=
#SITE_PROXY_PORT=
DASHBOARD_TAG=33cef775a8a6228cbacee4a09ac2c4073d62ed13
DASHBOARD_CONTAINER_NAME=tt-convex-dashboard
DASHBOARD_DOMAIN=dashboard.convex.gbrown.org
#DASHBOARD_PORT
INSTANCE_NAME=Convex.gib
#INSTANCE_SECRET=
CONVEX_CLOUD_ORIGIN=https://api.convex.gbrown.org
CONVEX_SITE_ORIGIN=https://convex.gbrown.org
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.convex.gbrown.org
#POSTGRES_URL=
#DATABASE_URL=
#CONVEX_RELEASE_VERSION_DEV=
#ACTIONS_USER_TIMEOUT_SECS=
#MYSQL_URL=
#RUST_LOG=
#RUST_BACKTRACE=

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env bash
set -e # Exit immediately if a command exits with a non-zero status.
# --- Configuration ---
COMPOSE_FILE="./docker/compose.yml"
DEFAULT_PROJECT_NAME="techtracker"
DEV_PROJECT_NAME="dev-techtracker" # The project name for dev mode
COMPOSE_PROJECT_FLAG=${DEFAULT_PROJECT_NAME} # This will hold "-p dev-techtracker" if --dev is used
# --- Function to display usage ---
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Or: ./update.sh [OPTIONS]" # Assuming the script is named update.sh
echo ""
echo "Options:"
echo " -d, --dev Run in development mode, using project name '${DEV_PROJECT_NAME}'."
echo " Adds '-p ${DEV_PROJECT_NAME}' to docker compose commands."
echo " -h, --help Display this help message."
exit 1
}
# --- Parse arguments ---
while [[ "$#" -gt 0 ]]; do
case "$1" in
-d|--dev)
COMPOSE_PROJECT_FLAG=${DEV_PROJECT_NAME}
shift # Consume the argument
;;
-h|--help)
usage
;;
*)
echo "Error: Unknown argument '$1'" >&2
usage
;;
esac
done
# --- Main Script Logic ---
echo "\n--- Pulling latest git changes ---\n"
git pull
echo "\n--- Building Docker Compose services ${COMPOSE_PROJECT_FLAG} ---\n"
sudo docker compose -p ${COMPOSE_PROJECT_FLAG} -f "${COMPOSE_FILE}" build
echo "\n--- Bringing down Docker Compose services ${COMPOSE_PROJECT_FLAG} ---\n"
sudo docker compose -p ${COMPOSE_PROJECT_FLAG} -f "${COMPOSE_FILE}" down
echo "\n--- Bringing up Docker Compose services ${COMPOSE_PROJECT_FLAG} in detached mode ---\n"
sudo docker compose -p ${COMPOSE_PROJECT_FLAG} -f "${COMPOSE_FILE}" up -d
echo "\n--- Script finished successfully ---\n"

View File

@@ -14,7 +14,10 @@ export const baseConfig = {
'warn',
{ 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/no-misused-promises': [
'error',

View File

@@ -8,23 +8,20 @@
"packageManager": "bun@1.2.19",
"scripts": {
"dev": "turbo run dev",
"dev:tunnel": "turbo run dev:tunnel",
"build": "turbo run build",
"clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --cache"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.5.8",
"eslint": "^9.38.0",
"typescript": "^5.9.3",
"@types/node": "^20.19.23"
"turbo": "^2.5.6",
"eslint": "^9.35.0",
"typescript": "^5.9.2",
"@types/node": "^20.19.13"
},
"trustedDependencies": [
"@sentry/cli",
"@tailwindcss/oxide",
"esbuild",
"sharp",
"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
// functions.js
import { query } from './_generated/server';
import { v } from 'convex/values';
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
@@ -21,7 +21,7 @@ export const myQueryFunction = query({
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// 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.
console.log(args.first, args.second);
@@ -38,7 +38,7 @@ Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: 'hello',
second: "hello",
});
```
@@ -46,8 +46,8 @@ A mutation function looks like:
```ts
// functions.js
import { mutation } from './_generated/server';
import { v } from 'convex/values';
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
@@ -62,7 +62,7 @@ export const myMutationFunction = mutation({
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
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.
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);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: 'Hello!', second: 'me' });
mutation({ first: "Hello!", second: "me" });
// OR
// 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),
);
}

View File

@@ -8,21 +8,17 @@
* @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 {
ApiFromModules,
FilterApi,
FunctionReference,
} 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.
@@ -33,25 +29,18 @@ import type {
* ```
*/
declare const fullApi: ApiFromModules<{
CustomPassword: typeof CustomPassword;
auth: typeof auth;
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;
http: typeof http;
statuses: typeof statuses;
}>;
declare const fullApiWithMounts: typeof fullApi;
export declare const api: FilterApi<
typeof fullApiWithMounts,
typeof fullApi,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi<
typeof fullApiWithMounts,
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

View File

@@ -8,7 +8,7 @@
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
import { anyApi } from "convex/server";
/**
* 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 internal = anyApi;
export const components = componentsGeneric();

View File

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

View File

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

View File

@@ -7,22 +7,11 @@ import {
} from '@convex-dev/auth/server';
import { api } from './_generated/api';
import { type Id } from './_generated/dataModel';
import {
action,
mutation,
query,
type MutationCtx,
type QueryCtx,
} from './_generated/server';
import Authentik from '@auth/core/providers/authentik';
import { Entra, Password, validatePassword } from './custom/auth';
import { action, mutation, query } from './_generated/server';
import Password from './CustomPassword';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Authentik({ allowDangerousEmailAccountLinking: true }),
Entra,
Password,
],
providers: [Password],
});
export const PASSWORD_MIN = 8;
@@ -30,120 +19,96 @@ export const PASSWORD_MAX = 100;
export const PASSWORD_REGEX =
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
type RWCtx = MutationCtx | QueryCtx;
type User = {
id: Id<'users'>;
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> => {
export const getUser = query(async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const image: Id<'_storage'> | null =
typeof user.image === 'string' && user.image.length > 0
? (user.image as Id<'_storage'>)
: null;
const authAccount = await getUserAuthAccountData(ctx, userId);
return {
id: user._id,
email: user.email ?? null,
name: user.name ?? null,
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) => {
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) => {
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: {
name: v.optional(v.string()),
email: v.optional(v.string()),
image: v.optional(v.id('_storage')),
lunchTime: v.optional(v.string()),
automaticLunch: v.optional(v.boolean()),
name: v.string(),
},
handler: async (ctx, args) => {
handler: async (ctx, { name }) => {
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.');
if (args.lunchTime !== undefined && !args.lunchTime.includes(':')) {
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);
}
await ctx.db.patch(userId, { name });
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({
args: {
currentPassword: v.string(),
@@ -152,7 +117,7 @@ export const updateUserPassword = action({
handler: async (ctx, { currentPassword, newPassword }) => {
const userId = await getAuthUserId(ctx);
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.');
const verified = await retrieveAccount(ctx, {
provider: 'password',

View File

@@ -1,20 +1,12 @@
// convex/crons.ts
import { cronJobs } from 'convex/server';
import { api } from './_generated/api';
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
const crons = cronJobs();
// Runs at 5:00 PM America/Chicago, MondayFriday.
// Convex will handle DST if your project version supports `timeZone`.
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)',
'0 22 * * 1-5',
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 { v, VId } from 'convex/values';
import { v } from 'convex/values';
import { authTables } from '@convex-dev/auth/server';
// The schema is normally optional, but Convex Auth
@@ -12,8 +12,6 @@ export default defineSchema({
image: v.optional(v.string()),
email: v.optional(v.string()),
currentStatusId: v.optional(v.id('statuses')),
lunchTime: v.optional(v.string()),
automaticLunch: v.optional(v.boolean()),
emailVerificationTime: v.optional(v.number()),
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
@@ -26,7 +24,6 @@ export default defineSchema({
message: v.string(),
updatedAt: v.number(),
updatedBy: v.optional(v.id('users')),
persistentStatus: v.optional(v.boolean()),
})
.index('by_user', ['userId'])
.index('by_user_updatedAt', ['userId', 'updatedAt']),

View File

@@ -4,6 +4,7 @@ import {
type MutationCtx,
type QueryCtx,
action,
internalMutation,
mutation,
query,
} from './_generated/server';
@@ -12,6 +13,7 @@ import type { Doc, Id } from './_generated/dataModel';
import { paginationOptsValidator } from 'convex/server';
type RWCtx = MutationCtx | QueryCtx;
type StatusRow = {
user: {
id: Id<'users'>;
@@ -24,7 +26,6 @@ type StatusRow = {
message: string;
updatedAt: number;
updatedBy: StatusRow['user'] | null;
persistentStatus: boolean;
} | null;
};
@@ -34,73 +35,72 @@ type Paginated<T> = {
continueCursor: string | null;
};
// CHANGED: typed helpers
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
return user;
};
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;
const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
const [latest] = await ctx.db
.query('statuses')
.withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
.order('desc')
.take(1);
return latest as Doc<'statuses'> | 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({
args: {
message: v.string(),
userId: v.optional(v.id('users')),
updatedBy: v.optional(v.id('users')),
persistentStatus: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const authUserId: Id<'users'> | null = await getAuthUserId(ctx);
if (!args.userId && !authUserId) {
throw new ConvexError('Not authenticated.');
}
const authUserId = await getAuthUserId(ctx);
if (!authUserId) 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();
if (message.length === 0) {
throw new ConvexError('Message cannot be empty.');
}
const userId = args.userId ?? authUserId!;
const updatedBy = args.updatedBy ?? authUserId;
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,
userId,
updatedBy,
updatedAt: Date.now(),
persistentStatus,
});
} else {
statusId = await ctx.db.insert('statuses', {
message,
userId,
updatedAt: Date.now(),
persistentStatus,
});
}
const statusId = await ctx.db.insert('statuses', {
message,
userId,
updatedBy,
updatedAt: Date.now(),
});
await ctx.db.patch(userId, { currentStatusId: 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({
args: {
message: v.string(),
userIds: v.array(v.id('users')),
updatedBy: v.optional(v.id('users')),
persistentStatus: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx);
@@ -109,7 +109,6 @@ export const bulkCreate = mutation({
if (args.userIds.length === 0) return { statusIds: [] };
const updatedBy = args.updatedBy ?? authUserId;
const persistentStatus = args.persistentStatus ?? false;
await ensureUser(ctx, updatedBy);
const message = args.message.trim();
@@ -120,130 +119,91 @@ export const bulkCreate = mutation({
const statusIds: Id<'statuses'>[] = [];
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) {
await ensureUser(ctx, userId);
const statusId = await ctx.db.insert('statuses', {
message,
userId,
updatedBy,
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 });
statusIds.push(statusId);
}
return { statusIds };
},
});
export const updateAllStatuses = mutation({
args: {
message: v.string(),
persistentStatus: v.optional(v.boolean())
},
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,
});
}
},
});
/**
* Current status for a specific user.
* - Uses users.currentStatusId if present,
* otherwise falls back to latest by index.
*/
export const getCurrentForUser = query({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
const user = await ensureUser(ctx, userId);
if (user.currentStatusId) {
const status = await ctx.db.get(user.currentStatusId);
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({
args: {},
handler: async (ctx): Promise<StatusRow[]> => {
const users = await ctx.db.query('users').collect();
return await Promise.all(
users.map(async (u) => {
// Resolve user's current or latest status
let curStatus: Doc<'statuses'> | null = null;
if ('currentStatusId' in u && u.currentStatusId) {
curStatus = await ctx.db.get(u.currentStatusId);
@@ -256,35 +216,39 @@ export const getCurrentForAll = query({
.take(1);
curStatus = latest ?? null;
}
// User display + URL
const userImageId = getImageId(u);
const userImageUrl = userImageId
? await ctx.storage.getUrl(userImageId)
: null;
// Updated by (if different) + URL
let updatedByUser: StatusRow['user'] | null = null;
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
const updater = await ctx.db.get(curStatus.updatedBy);
if (updater) {
const updaterImageId = getImageId(updater);
const updaterImageUrl = updaterImageId
? await ctx.storage.getUrl(updaterImageId)
: null;
updatedByUser = {
id: updater._id,
email: getEmail(updater),
name: getName(updater),
imageUrl: updaterImageUrl,
};
}
if (!updater) throw new ConvexError('Updater not found.');
const updaterImageId = getImageId(updater);
const updaterImageUrl = updaterImageId
? await ctx.storage.getUrl(updaterImageId)
: null;
updatedByUser = {
id: updater._id,
email: getEmail(updater),
name: getName(updater),
imageUrl: updaterImageUrl,
};
}
const status: StatusRow['status'] = curStatus
? {
id: curStatus._id,
message: curStatus.message,
updatedAt: curStatus.updatedAt,
updatedBy: updatedByUser,
persistentStatus: curStatus.persistentStatus ?? false,
}
: null;
return {
user: {
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({
args: {
userId: v.optional(v.id('users')),
@@ -309,6 +275,7 @@ export const listHistory = query({
ctx,
{ userId, paginationOpts },
): Promise<Paginated<StatusRow>> => {
// Query statuses newest-first, optionally filtered by user
const result = userId
? await ctx.db
.query('statuses')
@@ -316,15 +283,21 @@ export const listHistory = query({
.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 getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
const key = uid as unknown as string;
const cached = displayCache.get(key);
if (cached) return cached;
const user = await ctx.db.get(uid);
if (!user) throw new ConvexError('User not found.');
const imgId = getImageId(user);
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
const display: StatusRow['user'] = {
id: user._id,
email: getEmail(user),
@@ -334,6 +307,7 @@ export const listHistory = query({
displayCache.set(key, display);
return display;
};
const statuses: StatusRow[] = [];
for (const s of result.page) {
const owner = await getDisplay(s.userId);
@@ -341,6 +315,7 @@ export const listHistory = query({
s.updatedBy && s.updatedBy !== s.userId
? await getDisplay(s.updatedBy)
: null;
statuses.push({
user: owner,
status: {
@@ -348,12 +323,15 @@ export const listHistory = query({
message: s.message,
updatedAt: s.updatedAt,
updatedBy,
persistentStatus: s.persistentStatus ?? false,
},
});
}
const page = statuses.sort(
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
);
return {
page: statuses,
page,
isDone: result.isDone,
continueCursor: result.continueCursor,
};
@@ -367,49 +345,17 @@ export const endOfShiftUpdate = action({
timeZone: 'America/Chicago',
}),
);
const day = now.getDay(),
hour = now.getHours(),
minute = now.getMinutes();
const day = now.getDay();
const hour = now.getHours();
const minute = now.getMinutes();
if (day == 0 || day === 6) return;
const message = day === 5 ? 'Enjoying the weekend' : 'End of shift';
if (hour === 17) {
await ctx.runMutation(api.statuses.updateAllStatuses, { message });
} else if (hour === 16) {
if (hour === 5) {
await ctx.runMutation(api.statuses.updateAllStatuses, {
message: 'End of shift',
});
} else if (hour === 4) {
const ms = ((60 - minute) % 60) * 60 * 1000;
await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate);
} 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",
"scripts": {
"dev": "convex dev",
"dev:tunnel": "convex dev",
"predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard",
"setup": "convex dev --until-success"
},
"author": "Gib",
"license": "ISC",
"dependencies": {
"@oslojs/crypto": "^1.0.1",
"@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"
"convex": "^1.27.0"
},
"devDependencies": {
"react-email": "4.2.11",
"devDependencies": {
"typescript": "5.9.2"
}
}

View File

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