Compare commits
4 Commits
main
...
879a46ccac
| Author | SHA1 | Date | |
|---|---|---|---|
| 879a46ccac | |||
| 8912968d75 | |||
| e3579011e8 | |||
| e68638ec9c |
@@ -38,13 +38,11 @@ You will also need docker installed on whatever host you plan to run the Convex
|
|||||||
Copy the example environment variable files and paste them in the same directory named `.env`.
|
Copy the example environment variable files and paste them in the same directory named `.env`.
|
||||||
|
|
||||||
Environment variables for Next Application
|
Environment variables for Next Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp ./apps/next/env.example ./apps/next/.env
|
cp ./apps/next/env.example ./apps/next/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment variables for Self Hosting Convex & Website with Docker
|
Environment variables for Self Hosting Convex & Website with Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp ./docker/env.example ./docker/.env
|
cp ./docker/env.example ./docker/.env
|
||||||
```
|
```
|
||||||
@@ -52,7 +50,6 @@ cp ./docker/env.example ./docker/.env
|
|||||||
### Start self hosted convex & Next Web Application
|
### Start self hosted convex & Next Web Application
|
||||||
|
|
||||||
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
|
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ./docker
|
cd ./docker
|
||||||
sudo docker compose up -d
|
sudo docker compose up -d
|
||||||
@@ -63,9 +60,9 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
|
|||||||
|
|
||||||
Run
|
Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fin
|
### Fin
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,25 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Tech Tracker",
|
"name": "techtracker-expo",
|
||||||
"owner": "gib",
|
|
||||||
"slug": "techtracker-expo",
|
"slug": "techtracker-expo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "org.gbrown.techtrackerexpo",
|
"scheme": "techtrackerexpo",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#2e2f3d"
|
|
||||||
},
|
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"usesAppleSignIn": true,
|
"supportsTablet": true
|
||||||
"supportsTablet": true,
|
|
||||||
"bundleIdentifier": "com.gbrown.techtracker",
|
|
||||||
"config": {
|
|
||||||
"usesNonExemptEncryption": false
|
|
||||||
},
|
|
||||||
"infoPlist": {
|
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
|
||||||
"NSLocationWhenInUseUsageDescription": "This app uses your location in order to allow you to share your location in chat.",
|
|
||||||
"NSCameraUsageDescription": "This app uses your camera to take photos & send them in the chat."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"backgroundColor": "#E6F4FE",
|
||||||
"backgroundColor": "#2e2f3d"
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"edgeToEdgeEnabled": true,
|
||||||
"android.permission.ACCESS_COARSE_LOCATION",
|
"predictiveBackGestureEnabled": false
|
||||||
"android.permission.ACCESS_FINE_LOCATION",
|
|
||||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
|
||||||
"android.permission.VIBRATE",
|
|
||||||
"android.permission.INTERNET"
|
|
||||||
],
|
|
||||||
"package": "com.gbrown.techtracker"
|
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
@@ -47,7 +27,6 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-apple-authentication",
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -59,27 +38,6 @@
|
|||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-secure-store",
|
|
||||||
{
|
|
||||||
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your FaceID biometric data."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-location",
|
|
||||||
{
|
|
||||||
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"@sentry/react-native/expo",
|
|
||||||
{
|
|
||||||
"url": "https://sentry.gbrown.org",
|
|
||||||
"note": "Use SENTRY_AUTH_TOKEN env to authenticate with Sentry.",
|
|
||||||
"project": "tech-tracker-expo",
|
|
||||||
"organization": "gib"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
Binary file not shown.
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 |
@@ -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;
|
|
||||||
@@ -3,46 +3,42 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
"dev": "expo start",
|
"dev": "expo start",
|
||||||
"dev:tunnel": "expo start --tunnel",
|
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@react-navigation/bottom-tabs": "^7.6.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.7.1",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.19",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@sentry/react-native": "^7.4.0",
|
"expo": "~54.0.4",
|
||||||
"expo": "~54.0.20",
|
"expo-constants": "~18.0.8",
|
||||||
"expo-apple-authentication": "~8.0.7",
|
"expo-font": "~14.0.8",
|
||||||
"expo-constants": "~18.0.10",
|
|
||||||
"expo-font": "~14.0.9",
|
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.8",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
"expo-location": "~19.0.7",
|
"expo-router": "~6.0.2",
|
||||||
"expo-router": "~6.0.13",
|
"expo-splash-screen": "~31.0.9",
|
||||||
"expo-secure-store": "~15.0.7",
|
|
||||||
"expo-splash-screen": "~31.0.10",
|
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
"expo-system-ui": "~6.0.8",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.4",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-reanimated": "~4.1.3",
|
"react-native-worklets": "0.5.1",
|
||||||
"react-native-safe-area-context": "~5.6.1",
|
"react-native-reanimated": "~4.1.0",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-web": "~0.21.2",
|
"react-native-web": "~0.21.0"
|
||||||
"react-native-worklets": "0.5.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.17",
|
"@types/react": "~19.1.0",
|
||||||
"eslint-config-expo": "~10.0.0"
|
"eslint-config-expo": "~10.0.0"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
@@ -15,24 +15,19 @@ export default function TabLayout() {
|
|||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarButton: HapticTab,
|
tabBarButton: HapticTab,
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name='index'
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Home',
|
title: 'Home',
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||||
<IconSymbol size={28} name='house.fill' color={color} />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name='explore'
|
name="explore"
|
||||||
options={{
|
options={{
|
||||||
title: 'Explore',
|
title: 'Explore',
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||||
<IconSymbol size={28} name='paperplane.fill' color={color} />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { Platform, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
import { Collapsible } from '@/components/ui/collapsible';
|
import { Collapsible } from '@/components/ui/collapsible';
|
||||||
import { ExternalLink } from '@/components/external-link';
|
import { ExternalLink } from '@/components/external-link';
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||||
@@ -15,82 +16,71 @@ export default function TabTwoScreen() {
|
|||||||
headerImage={
|
headerImage={
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
size={310}
|
size={310}
|
||||||
color='#808080'
|
color="#808080"
|
||||||
name='chevron.left.forwardslash.chevron.right'
|
name="chevron.left.forwardslash.chevron.right"
|
||||||
style={styles.headerImage}
|
style={styles.headerImage}
|
||||||
/>
|
/>
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
<ThemedView style={styles.titleContainer}>
|
||||||
<ThemedText
|
<ThemedText
|
||||||
type='title'
|
type="title"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: Fonts.rounded,
|
fontFamily: Fonts.rounded,
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
Explore
|
Explore
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
<ThemedText>
|
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||||
This app includes example code to help you get started.
|
<Collapsible title="File-based routing">
|
||||||
</ThemedText>
|
|
||||||
<Collapsible title='File-based routing'>
|
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
This app has two screens:{' '}
|
This app has two screens:{' '}
|
||||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||||
and{' '}
|
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||||
<ThemedText type='defaultSemiBold'>app/(tabs)/explore.tsx</ThemedText>
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
The layout file in{' '}
|
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||||
<ThemedText type='defaultSemiBold'>app/(tabs)/_layout.tsx</ThemedText>{' '}
|
|
||||||
sets up the tab navigator.
|
sets up the tab navigator.
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<ExternalLink href='https://docs.expo.dev/router/introduction'>
|
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||||
<ThemedText type='link'>Learn more</ThemedText>
|
<ThemedText type="link">Learn more</ThemedText>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<Collapsible title='Android, iOS, and web support'>
|
<Collapsible title="Android, iOS, and web support">
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
You can open this project on Android, iOS, and the web. To open the
|
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||||
web version, press <ThemedText type='defaultSemiBold'>w</ThemedText>{' '}
|
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||||
in the terminal running this project.
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<Collapsible title='Images'>
|
<Collapsible title="Images">
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
For static images, you can use the{' '}
|
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||||
<ThemedText type='defaultSemiBold'>@2x</ThemedText> and{' '}
|
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||||
<ThemedText type='defaultSemiBold'>@3x</ThemedText> suffixes to
|
different screen densities
|
||||||
provide files for different screen densities
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<Image
|
<Image
|
||||||
source={require('assets/images/react-logo.png')}
|
source={require('@/assets/images/react-logo.png')}
|
||||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||||
/>
|
/>
|
||||||
<ExternalLink href='https://reactnative.dev/docs/images'>
|
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||||
<ThemedText type='link'>Learn more</ThemedText>
|
<ThemedText type="link">Learn more</ThemedText>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<Collapsible title='Light and dark mode components'>
|
<Collapsible title="Light and dark mode components">
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
This template has light and dark mode support. The{' '}
|
This template has light and dark mode support. The{' '}
|
||||||
<ThemedText type='defaultSemiBold'>useColorScheme()</ThemedText> hook
|
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||||
lets you inspect what the user's current color scheme is, and so
|
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||||
you can adjust UI colors accordingly.
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<ExternalLink href='https://docs.expo.dev/develop/user-interface/color-themes/'>
|
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||||
<ThemedText type='link'>Learn more</ThemedText>
|
<ThemedText type="link">Learn more</ThemedText>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<Collapsible title='Animations'>
|
<Collapsible title="Animations">
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
This template includes an example of an animated component. The{' '}
|
This template includes an example of an animated component. The{' '}
|
||||||
<ThemedText type='defaultSemiBold'>
|
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||||
components/HelloWave.tsx
|
the powerful{' '}
|
||||||
</ThemedText>{' '}
|
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||||
component uses the powerful{' '}
|
|
||||||
<ThemedText type='defaultSemiBold' style={{ fontFamily: Fonts.mono }}>
|
|
||||||
react-native-reanimated
|
react-native-reanimated
|
||||||
</ThemedText>{' '}
|
</ThemedText>{' '}
|
||||||
library to create a waving hand animation.
|
library to create a waving hand animation.
|
||||||
@@ -98,10 +88,7 @@ export default function TabTwoScreen() {
|
|||||||
{Platform.select({
|
{Platform.select({
|
||||||
ios: (
|
ios: (
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
The{' '}
|
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||||
<ThemedText type='defaultSemiBold'>
|
|
||||||
components/ParallaxScrollView.tsx
|
|
||||||
</ThemedText>{' '}
|
|
||||||
component provides a parallax effect for the header image.
|
component provides a parallax effect for the header image.
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { Platform, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
import { HelloWave } from '@/components/hello-wave';
|
import { HelloWave } from '@/components/hello-wave';
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
@@ -12,22 +13,20 @@ export default function HomeScreen() {
|
|||||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||||
headerImage={
|
headerImage={
|
||||||
<Image
|
<Image
|
||||||
source={require('assets/images/partial-react-logo.png')}
|
source={require('@/assets/images/partial-react-logo.png')}
|
||||||
style={styles.reactLogo}
|
style={styles.reactLogo}
|
||||||
/>
|
/>
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
<ThemedView style={styles.titleContainer}>
|
||||||
<ThemedText type='title'>Welcome!</ThemedText>
|
<ThemedText type="title">Welcome!</ThemedText>
|
||||||
<HelloWave />
|
<HelloWave />
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
<ThemedView style={styles.stepContainer}>
|
<ThemedView style={styles.stepContainer}>
|
||||||
<ThemedText type='subtitle'>Step 1: Try it</ThemedText>
|
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
Edit{' '}
|
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
Press{' '}
|
||||||
to see changes. Press{' '}
|
<ThemedText type="defaultSemiBold">
|
||||||
<ThemedText type='defaultSemiBold'>
|
|
||||||
{Platform.select({
|
{Platform.select({
|
||||||
ios: 'cmd + d',
|
ios: 'cmd + d',
|
||||||
android: 'cmd + m',
|
android: 'cmd + m',
|
||||||
@@ -38,26 +37,22 @@ export default function HomeScreen() {
|
|||||||
</ThemedText>
|
</ThemedText>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
<ThemedView style={styles.stepContainer}>
|
<ThemedView style={styles.stepContainer}>
|
||||||
<Link href='/modal'>
|
<Link href="/modal">
|
||||||
<Link.Trigger>
|
<Link.Trigger>
|
||||||
<ThemedText type='subtitle'>Step 2: Explore</ThemedText>
|
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||||
</Link.Trigger>
|
</Link.Trigger>
|
||||||
<Link.Preview />
|
<Link.Preview />
|
||||||
<Link.Menu>
|
<Link.Menu>
|
||||||
|
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title='Action'
|
title="Share"
|
||||||
icon='cube'
|
icon="square.and.arrow.up"
|
||||||
onPress={() => alert('Action pressed')}
|
|
||||||
/>
|
|
||||||
<Link.MenuAction
|
|
||||||
title='Share'
|
|
||||||
icon='square.and.arrow.up'
|
|
||||||
onPress={() => alert('Share pressed')}
|
onPress={() => alert('Share pressed')}
|
||||||
/>
|
/>
|
||||||
<Link.Menu title='More' icon='ellipsis'>
|
<Link.Menu title="More" icon="ellipsis">
|
||||||
<Link.MenuAction
|
<Link.MenuAction
|
||||||
title='Delete'
|
title="Delete"
|
||||||
icon='trash'
|
icon="trash"
|
||||||
destructive
|
destructive
|
||||||
onPress={() => alert('Delete pressed')}
|
onPress={() => alert('Delete pressed')}
|
||||||
/>
|
/>
|
||||||
@@ -70,16 +65,13 @@ export default function HomeScreen() {
|
|||||||
</ThemedText>
|
</ThemedText>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
<ThemedView style={styles.stepContainer}>
|
<ThemedView style={styles.stepContainer}>
|
||||||
<ThemedText type='subtitle'>Step 3: Get a fresh start</ThemedText>
|
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||||
<ThemedText>
|
<ThemedText>
|
||||||
{`When you're ready, run `}
|
{`When you're ready, run `}
|
||||||
<ThemedText type='defaultSemiBold'>
|
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||||
npm run reset-project
|
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||||
</ThemedText>{' '}
|
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||||
to get a fresh <ThemedText type='defaultSemiBold'>app</ThemedText>{' '}
|
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||||
directory. This will move the current{' '}
|
|
||||||
<ThemedText type='defaultSemiBold'>app</ThemedText> to{' '}
|
|
||||||
<ThemedText type='defaultSemiBold'>app-example</ThemedText>.
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
|
|||||||
@@ -1,44 +1,24 @@
|
|||||||
import {
|
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
DarkTheme,
|
|
||||||
DefaultTheme,
|
|
||||||
ThemeProvider,
|
|
||||||
} from '@react-navigation/native';
|
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
import * as Sentry from '@sentry/react-native';
|
|
||||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: 'https://ff2e19b7c72ee50463c6c66b5bef7ce0@sentry.gbrown.org/8',
|
|
||||||
sendDefaultPii: true,
|
|
||||||
tracesSampleRate: 1.0,
|
|
||||||
profilesSampleRate: 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
anchor: '(tabs)',
|
anchor: '(tabs)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RootLayout = () => {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConvexProvider client={convex}>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<Stack>
|
||||||
<Stack>
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
|
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||||
<Stack.Screen
|
</Stack>
|
||||||
name='modal'
|
<StatusBar style="auto" />
|
||||||
options={{ presentation: 'modal', title: 'Modal' }}
|
</ThemeProvider>
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<StatusBar style='auto' />
|
|
||||||
</ThemeProvider>
|
|
||||||
</ConvexProvider>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
export default Sentry.wrap(RootLayout);
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { ThemedView } from '@/components/themed-view';
|
|||||||
export default function ModalScreen() {
|
export default function ModalScreen() {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>This is a modal</ThemedText>
|
<ThemedText type="title">This is a modal</ThemedText>
|
||||||
<Link href='/' dismissTo style={styles.link}>
|
<Link href="/" dismissTo style={styles.link}>
|
||||||
<ThemedText type='link'>Go to home screen</ThemedText>
|
<ThemedText type="link">Go to home screen</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { Href, Link } from 'expo-router';
|
import { Href, Link } from 'expo-router';
|
||||||
import {
|
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||||
openBrowserAsync,
|
|
||||||
WebBrowserPresentationStyle,
|
|
||||||
} from 'expo-web-browser';
|
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from 'react';
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||||
href: Href & string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
target='_blank'
|
target="_blank"
|
||||||
{...rest}
|
{...rest}
|
||||||
href={href}
|
href={href}
|
||||||
onPress={async (event) => {
|
onPress={async (event) => {
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export function HelloWave() {
|
|||||||
},
|
},
|
||||||
animationIterationCount: 4,
|
animationIterationCount: 4,
|
||||||
animationDuration: '300ms',
|
animationDuration: '300ms',
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
👋
|
👋
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,15 +34,11 @@ export default function ParallaxScrollView({
|
|||||||
translateY: interpolate(
|
translateY: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: interpolate(
|
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||||
scrollOffset.value,
|
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -52,15 +48,13 @@ export default function ParallaxScrollView({
|
|||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
style={{ backgroundColor, flex: 1 }}
|
style={{ backgroundColor, flex: 1 }}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}>
|
||||||
>
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.header,
|
styles.header,
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||||
headerAnimatedStyle,
|
headerAnimatedStyle,
|
||||||
]}
|
]}>
|
||||||
>
|
|
||||||
{headerImage}
|
{headerImage}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||||
|
|||||||
@@ -7,16 +7,8 @@ export type ThemedViewProps = ViewProps & {
|
|||||||
darkColor?: string;
|
darkColor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ThemedView({
|
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||||
style,
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
lightColor,
|
|
||||||
darkColor,
|
|
||||||
...otherProps
|
|
||||||
}: ThemedViewProps) {
|
|
||||||
const backgroundColor = useThemeColor(
|
|
||||||
{ light: lightColor, dark: darkColor },
|
|
||||||
'background',
|
|
||||||
);
|
|
||||||
|
|
||||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
|
|||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from '@/constants/theme';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
|
||||||
export function Collapsible({
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
children,
|
|
||||||
title,
|
|
||||||
}: PropsWithChildren & { title: string }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
|
||||||
@@ -19,17 +16,16 @@ export function Collapsible({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.heading}
|
style={styles.heading}
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
onPress={() => setIsOpen((value) => !value)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}>
|
||||||
>
|
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name='chevron.right'
|
name="chevron.right"
|
||||||
size={18}
|
size={18}
|
||||||
weight='medium'
|
weight="medium"
|
||||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ThemedText type='defaultSemiBold'>{title}</ThemedText>
|
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function IconSymbol({
|
|||||||
<SymbolView
|
<SymbolView
|
||||||
weight={weight}
|
weight={weight}
|
||||||
tintColor={color}
|
tintColor={color}
|
||||||
resizeMode='scaleAspectFit'
|
resizeMode="scaleAspectFit"
|
||||||
name={name}
|
name={name}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
|||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||||
|
|
||||||
type IconMapping = Record<
|
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||||
SymbolViewProps['name'],
|
|
||||||
ComponentProps<typeof MaterialIcons>['name']
|
|
||||||
>;
|
|
||||||
type IconSymbolName = keyof typeof MAPPING;
|
type IconSymbolName = keyof typeof MAPPING;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,12 +37,5 @@ export function IconSymbol({
|
|||||||
style?: StyleProp<TextStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
weight?: SymbolWeight;
|
weight?: SymbolWeight;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||||
<MaterialIcons
|
|
||||||
color={color}
|
|
||||||
size={size}
|
|
||||||
name={MAPPING[name]}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,7 @@ export const Fonts = Platform.select({
|
|||||||
web: {
|
web: {
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
serif: "Georgia, 'Times New Roman', serif",
|
||||||
rounded:
|
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||||
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
|
|||||||
|
|
||||||
export function useThemeColor(
|
export function useThemeColor(
|
||||||
props: { light?: string; dark?: string },
|
props: { light?: string; dark?: string },
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
) {
|
) {
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme() ?? 'light';
|
||||||
const colorFromProps = props[theme];
|
const colorFromProps = props[theme];
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"jsx": "react-jsx",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"assets/*": ["./assets/*"],
|
"@/*": ["./src/*"]
|
||||||
"@/*": ["./src/*"],
|
|
||||||
"~/*": ["../../packages/backend/*"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
apps/next/.dockerignore
Normal file
8
apps/next/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
docker-compose*.yml
|
||||||
|
host/
|
||||||
3
apps/next/.gitignore
vendored
3
apps/next/.gitignore
vendored
@@ -45,6 +45,3 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Ignored for the template, you probably want to remove it:
|
# Ignored for the template, you probably want to remove it:
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# Sentry Config File
|
|
||||||
.env.sentry-build-plugin
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
### Server Variables ###
|
### Server Variables ###
|
||||||
# Next
|
# Convex
|
||||||
NODE_ENV=
|
CONVEX_SELF_HOSTED_URL=
|
||||||
SKIP_ENV_VALIDATION=
|
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||||
SITE_URL=
|
NEXT_PUBLIC_CONVEX_URL=
|
||||||
|
SETUP_SCRIPT_RAN=
|
||||||
# Sentry
|
# Sentry
|
||||||
SENTRY_AUTH_TOKEN=
|
SENTRY_AUTH_TOKEN=
|
||||||
CI=
|
|
||||||
|
|
||||||
### Client Variables ###
|
### Client Variables ###
|
||||||
# Next
|
# Next # Default Values:
|
||||||
NEXT_PUBLIC_SITE_URL=
|
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
|
||||||
# Convex
|
# Sentry # Default Values
|
||||||
NEXT_PUBLIC_CONVEX_URL=
|
|
||||||
# Sentry
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
NEXT_PUBLIC_SENTRY_URL=
|
NEXT_PUBLIC_SENTRY_URL=
|
||||||
NEXT_PUBLIC_SENTRY_ORG=
|
NEXT_PUBLIC_SENTRY_ORG=
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { env } from './src/env.js';
|
import './src/env.js';
|
||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
import { withPlausibleProxy } from 'next-plausible';
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
|
|||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: env.NEXT_PUBLIC_SENTRY_ORG,
|
org: 'gib',
|
||||||
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||||
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
authToken: env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !env.CI,
|
silent: !process.env.CI,
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"dev:tunnel": "next dev --turbo",
|
|
||||||
"dev:slow": "next dev",
|
"dev:slow": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -14,9 +13,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/auth": "^0.0.81",
|
"@convex-dev/auth": "^0.0.81",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -24,41 +22,39 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@sentry/nextjs": "^10.22.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"convex": "^1.28.0",
|
"convex": "^1.27.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"next": "^15.5.6",
|
"next": "^15.5.3",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
"require-in-the-middle": "^7.5.2",
|
"require-in-the-middle": "^7.5.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.43.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/node": "^20.19.23",
|
"@types/node": "^20.19.13",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.1.9",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"eslint-config-next": "^15.5.6",
|
"eslint-config-next": "^15.5.3",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.3.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 425 KiB |
@@ -1,9 +1,9 @@
|
|||||||
import { env } from './src/env.js';
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import './src/env.js';
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
tracesSampleRate: 1,
|
tracesSampleRate: 1,
|
||||||
enableLogs: true,
|
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -18,7 +18,8 @@ const Profile = async () => {
|
|||||||
<AvatarUpload preloadedUser={preloadedUser} />
|
<AvatarUpload preloadedUser={preloadedUser} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<UserInfoForm preloadedUser={preloadedUser} />
|
<UserInfoForm preloadedUser={preloadedUser} />
|
||||||
<ResetPasswordForm preloadedUser={preloadedUser} />
|
<Separator />
|
||||||
|
<ResetPasswordForm />
|
||||||
<Separator />
|
<Separator />
|
||||||
<SignOutForm />
|
<SignOutForm />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -12,17 +12,11 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot,
|
|
||||||
InputOTPSeparator,
|
|
||||||
Separator,
|
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -30,10 +24,6 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
|
||||||
GibsAuthSignInButton,
|
|
||||||
MicrosoftSignInButton,
|
|
||||||
} from '@/components/layout/auth/buttons';
|
|
||||||
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
|
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
|
||||||
|
|
||||||
const signInFormSchema = z.object({
|
const signInFormSchema = z.object({
|
||||||
@@ -50,7 +40,7 @@ const signUpFormSchema = z
|
|||||||
name: z.string().min(2, {
|
name: z.string().min(2, {
|
||||||
message: 'Name must be at least 2 characters.',
|
message: 'Name must be at least 2 characters.',
|
||||||
}),
|
}),
|
||||||
email: z.email({
|
email: z.string().email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z
|
password: z
|
||||||
@@ -85,17 +75,9 @@ const signUpFormSchema = z
|
|||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const verifyEmailFormSchema = z.object({
|
|
||||||
code: z.string({ message: 'Invalid code.' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = () => {
|
||||||
const { signIn } = useAuthActions();
|
const { signIn } = useAuthActions();
|
||||||
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
||||||
'signIn',
|
|
||||||
);
|
|
||||||
const [email, setEmail] = useState<string>('');
|
|
||||||
const [code, setCode] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -108,17 +90,12 @@ const SignIn = () => {
|
|||||||
resolver: zodResolver(signUpFormSchema),
|
resolver: zodResolver(signUpFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
email,
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
|
||||||
resolver: zodResolver(verifyEmailFormSchema),
|
|
||||||
defaultValues: { code },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', values.email);
|
formData.append('email', values.email);
|
||||||
@@ -126,12 +103,13 @@ const SignIn = () => {
|
|||||||
formData.append('flow', flow);
|
formData.append('flow', flow);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn('password', formData).then(() => router.push('/'));
|
await signIn('password', formData);
|
||||||
|
signInForm.reset();
|
||||||
|
router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing in:', error);
|
console.error('Error signing in:', error);
|
||||||
toast.error('Error signing in.');
|
toast.error('Error signing in.');
|
||||||
} finally {
|
} finally {
|
||||||
signInForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -146,107 +124,17 @@ const SignIn = () => {
|
|||||||
try {
|
try {
|
||||||
if (values.confirmPassword !== values.password)
|
if (values.confirmPassword !== values.password)
|
||||||
throw new ConvexError('Passwords do not match.');
|
throw new ConvexError('Passwords do not match.');
|
||||||
await signIn('password', formData).then(() => {
|
await signIn('password', formData);
|
||||||
setEmail(values.email);
|
signUpForm.reset();
|
||||||
setFlow('email-verification');
|
router.push('/');
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing up:', error);
|
console.error('Error signing up:', error);
|
||||||
toast.error('Error signing up.');
|
toast.error('Error signing up.');
|
||||||
} finally {
|
} finally {
|
||||||
signUpForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyEmail = async (
|
|
||||||
values: z.infer<typeof verifyEmailFormSchema>,
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('code', code);
|
|
||||||
formData.append('flow', flow);
|
|
||||||
formData.append('email', email);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await signIn('password', formData).then(() => router.push('/'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying email:', error);
|
|
||||||
toast.error('Error verifying email.');
|
|
||||||
} finally {
|
|
||||||
verifyEmailForm.reset();
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (flow === 'email-verification') {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
|
||||||
<CardContent>
|
|
||||||
<div className='text-center mb-6'>
|
|
||||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
|
||||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
|
||||||
</div>
|
|
||||||
<Form {...verifyEmailForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
|
||||||
className='flex flex-col space-y-8'
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={verifyEmailForm.control}
|
|
||||||
name='code'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className='text-xl'>Code</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<InputOTP
|
|
||||||
maxLength={6}
|
|
||||||
value={code}
|
|
||||||
onChange={(value) => setCode(value)}
|
|
||||||
>
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={0} />
|
|
||||||
<InputOTPSlot index={1} />
|
|
||||||
<InputOTPSlot index={2} />
|
|
||||||
<InputOTPSeparator />
|
|
||||||
<InputOTPSlot index={3} />
|
|
||||||
<InputOTPSlot index={4} />
|
|
||||||
<InputOTPSlot index={5} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Please enter the one-time password sent to your email.
|
|
||||||
</FormDescription>
|
|
||||||
<div className='flex flex-col w-full items-center'>
|
|
||||||
<FormMessage className='w-5/6 text-center' />
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SubmitButton
|
|
||||||
disabled={loading}
|
|
||||||
pendingText='Signing Up...'
|
|
||||||
className='text-xl font-semibold w-2/3 mx-auto'
|
|
||||||
>
|
|
||||||
Verify Email
|
|
||||||
</SubmitButton>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
<div className='text-center mt-4'>
|
|
||||||
<button
|
|
||||||
onClick={() => setFlow('signUp')}
|
|
||||||
className='text-sm text-muted-foreground hover:underline'
|
|
||||||
>
|
|
||||||
Back to Sign Up
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center'>
|
<div className='flex flex-col items-center'>
|
||||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||||
@@ -323,28 +211,12 @@ const SignIn = () => {
|
|||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing in...'
|
pendingText='Signing in...'
|
||||||
className='text-xl font-semibold w-2/3 mx-auto'
|
className='text-lg font-semibold w-2/3 mx-auto'
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<div className='flex justify-center'>
|
|
||||||
<div
|
|
||||||
className='flex flex-row items-center
|
|
||||||
my-2.5 mx-auto justify-center w-1/4'
|
|
||||||
>
|
|
||||||
<Separator className='py-0.5 mr-3' />
|
|
||||||
<span className='font-semibold text-lg'>or</span>
|
|
||||||
<Separator className='py-0.5 ml-3' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-center mb-3'>
|
|
||||||
<MicrosoftSignInButton />
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-center mt-3'>
|
|
||||||
<GibsAuthSignInButton />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -437,25 +309,12 @@ const SignIn = () => {
|
|||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing Up...'
|
pendingText='Signing Up...'
|
||||||
className='text-xl font-semibold w-2/3 mx-auto'
|
className='text-lg font-semibold w-2/3 mx-auto'
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<div className='flex my-auto justify-center w-2/3'>
|
|
||||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
|
||||||
<Separator className='py-0.5 mr-3' />
|
|
||||||
<span className='font-semibold text-lg'>or</span>
|
|
||||||
<Separator className='py-0.5 ml-3' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-center mb-3'>
|
|
||||||
<MicrosoftSignInButton type='signUp' />
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-center mt-3'>
|
|
||||||
<GibsAuthSignInButton type='signUp' />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -38,36 +38,38 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
|||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
return (
|
return (
|
||||||
<PlausibleProvider
|
<ConvexClientProvider>
|
||||||
domain='techtracker.gbrown.org'
|
<PlausibleProvider
|
||||||
customDomain='https://plausible.gbrown.org'
|
domain='techtracker.gbrown.org'
|
||||||
>
|
customDomain='https://plausible.gbrown.org'
|
||||||
<html lang='en' suppressHydrationWarning>
|
>
|
||||||
<body
|
<html lang='en' suppressHydrationWarning>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<body
|
||||||
>
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
<ThemeProvider
|
|
||||||
attribute='class'
|
|
||||||
defaultTheme='system'
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
>
|
||||||
<ConvexClientProvider>
|
<ThemeProvider
|
||||||
<TVModeProvider>
|
attribute='class'
|
||||||
<Header />
|
defaultTheme='system'
|
||||||
<main className='min-h-[90vh] flex flex-col items-center'>
|
enableSystem
|
||||||
<NextError statusCode={0} />
|
disableTransitionOnChange
|
||||||
{reset !== undefined && (
|
>
|
||||||
<Button onClick={() => reset()}>Try Again</Button>
|
<ConvexClientProvider>
|
||||||
)}
|
<TVModeProvider>
|
||||||
<Toaster />
|
<Header />
|
||||||
</main>
|
<main className='min-h-[90vh] flex flex-col items-center'>
|
||||||
</TVModeProvider>
|
<NextError statusCode={0} />
|
||||||
</ConvexClientProvider>
|
{reset !== undefined && (
|
||||||
</ThemeProvider>
|
<Button onClick={() => reset()}>Try Again</Button>
|
||||||
</body>
|
)}
|
||||||
</html>
|
<Toaster />
|
||||||
</PlausibleProvider>
|
</main>
|
||||||
|
</TVModeProvider>
|
||||||
|
</ConvexClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</PlausibleProvider>
|
||||||
|
</ConvexClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default GlobalError;
|
export default GlobalError;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import '@/styles/globals.css';
|
|||||||
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||||
import {
|
import {
|
||||||
ConvexClientProvider,
|
ConvexClientProvider,
|
||||||
LunchReminder,
|
|
||||||
NotificationsPermission,
|
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
TVModeProvider,
|
TVModeProvider,
|
||||||
} from '@/components/providers';
|
} from '@/components/providers';
|
||||||
@@ -24,11 +22,11 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
export const metadata: Metadata = generateMetadata();
|
export const metadata: Metadata = generateMetadata();
|
||||||
|
|
||||||
const RootLayout = async ({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) => {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<ConvexAuthNextjsServerProvider>
|
<ConvexAuthNextjsServerProvider>
|
||||||
<PlausibleProvider
|
<PlausibleProvider
|
||||||
@@ -50,8 +48,6 @@ const RootLayout = async ({
|
|||||||
<Header />
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<NotificationsPermission />
|
|
||||||
<LunchReminder />
|
|
||||||
</TVModeProvider>
|
</TVModeProvider>
|
||||||
</ConvexClientProvider>
|
</ConvexClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
@@ -60,5 +56,4 @@ const RootLayout = async ({
|
|||||||
</PlausibleProvider>
|
</PlausibleProvider>
|
||||||
</ConvexAuthNextjsServerProvider>
|
</ConvexAuthNextjsServerProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
export default RootLayout;
|
|
||||||
|
|||||||
@@ -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's Auth</p>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { GibsAuthSignInButton } from './gibs-auth';
|
|
||||||
export { MicrosoftSignInButton } from './microsoft';
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
import { type ChangeEvent, useRef, useState } from 'react';
|
import { type ChangeEvent, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
type Preloaded,
|
type Preloaded,
|
||||||
@@ -9,14 +10,13 @@ import {
|
|||||||
} from 'convex/react';
|
} from 'convex/react';
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
AvatarImage,
|
|
||||||
BasedAvatar,
|
BasedAvatar,
|
||||||
Button,
|
Button,
|
||||||
CardContent,
|
CardContent,
|
||||||
ImageCrop,
|
ImageCrop,
|
||||||
ImageCropApply,
|
ImageCropApply,
|
||||||
ImageCropContent,
|
ImageCropContent,
|
||||||
|
ImageCropReset,
|
||||||
Input,
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -48,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
const updateUser = useMutation(api.auth.updateUser);
|
const updateUserImage = useMutation(api.auth.updateUserImage);
|
||||||
|
|
||||||
const currentImageUrl = useQuery(
|
const currentImageUrl = useQuery(
|
||||||
api.files.getImageUrl,
|
api.files.getImageUrl,
|
||||||
@@ -97,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
storageId: Id<'_storage'>;
|
storageId: Id<'_storage'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateUser({ image: uploadResponse.storageId });
|
await updateUserImage({ storageId: uploadResponse.storageId });
|
||||||
|
|
||||||
toast.success('Profile picture updated.');
|
toast.success('Profile picture updated.');
|
||||||
handleReset();
|
handleReset();
|
||||||
@@ -121,7 +121,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={currentImageUrl ?? undefined}
|
src={currentImageUrl ?? undefined}
|
||||||
fullName={user?.name}
|
fullName={user?.name}
|
||||||
className='h-42 w-42 text-6xl font-semibold'
|
className='h-32 w-32'
|
||||||
|
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
||||||
userIconProps={{ size: 100 }}
|
userIconProps={{ size: 100 }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -172,6 +173,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
<ImageCropContent className='max-w-sm' />
|
<ImageCropContent className='max-w-sm' />
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<ImageCropApply />
|
<ImageCropApply />
|
||||||
|
<ImageCropReset />
|
||||||
<Button
|
<Button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
size='icon'
|
size='icon'
|
||||||
@@ -188,14 +190,19 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
{/* Cropped preview + actions */}
|
{/* Cropped preview + actions */}
|
||||||
{croppedImage && (
|
{croppedImage && (
|
||||||
<div className='flex flex-col items-center gap-3'>
|
<div className='flex flex-col items-center gap-3'>
|
||||||
<Avatar className='h-42 w-42'>
|
<Image
|
||||||
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
alt='Cropped preview'
|
||||||
</Avatar>
|
className='overflow-hidden rounded-full'
|
||||||
<div className='flex items-center gap-1'>
|
height={128}
|
||||||
|
src={croppedImage}
|
||||||
|
unoptimized
|
||||||
|
width={128}
|
||||||
|
/>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className='px-4'
|
className='px-6'
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<span className='inline-flex items-center gap-2'>
|
<span className='inline-flex items-center gap-2'>
|
||||||
@@ -210,10 +217,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
size='icon'
|
size='icon'
|
||||||
type='button'
|
type='button'
|
||||||
className='dark:bg-red-500/30 bg-red-400/80
|
variant='ghost'
|
||||||
hover:dark:text-red-300/60 hover:text-red-800/80
|
|
||||||
hover:dark:bg-accent'
|
|
||||||
variant='secondary'
|
|
||||||
>
|
>
|
||||||
<XIcon className='size-4' />
|
<XIcon className='size-4' />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { useAction } from 'convex/react';
|
import { useAction } from 'convex/react';
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { type Preloaded, usePreloadedQuery } from 'convex/react';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +18,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
Separator,
|
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -64,12 +62,7 @@ const formSchema = z
|
|||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResetFormProps = {
|
export const ResetPasswordForm = () => {
|
||||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
|
||||||
const user = usePreloadedQuery(preloadedUser);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const changePassword = useAction(api.auth.updateUserPassword);
|
const changePassword = useAction(api.auth.updateUserPassword);
|
||||||
@@ -101,12 +94,10 @@ export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return user?.provider !== 'password' ? (
|
|
||||||
<div />
|
return (
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<CardHeader className='pb-5'>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Update your password to keep your account secure
|
Update your password to keep your account secure
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -7,9 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
@@ -19,7 +16,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Switch,
|
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -36,11 +32,6 @@ const formSchema = z.object({
|
|||||||
email: z.email({
|
email: z.email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
lunchTime: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(3, { message: 'Must be a valid 24-hour time. Example: 13:00' }),
|
|
||||||
automaticLunch: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type UserInfoFormProps = {
|
type UserInfoFormProps = {
|
||||||
@@ -51,47 +42,28 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
|||||||
const user = usePreloadedQuery(preloadedUser);
|
const user = usePreloadedQuery(preloadedUser);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const updateUser = useMutation(api.auth.updateUser);
|
const updateUserName = useMutation(api.auth.updateUserName);
|
||||||
|
const updateUserEmail = useMutation(api.auth.updateUserEmail);
|
||||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
|
||||||
() => ({
|
|
||||||
name: user?.name ?? '',
|
|
||||||
email: user?.email ?? '',
|
|
||||||
lunchTime: user?.lunchTime ?? '12:00',
|
|
||||||
automaticLunch: user?.automaticLunch ?? false,
|
|
||||||
}),
|
|
||||||
[user?.name, user?.email, user?.lunchTime, user?.automaticLunch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
values: initialValues,
|
defaultValues: {
|
||||||
|
name: user?.name ?? '',
|
||||||
|
email: user?.email ?? '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
const ops: Promise<unknown>[] = [];
|
||||||
const name = values.name.trim();
|
const name = values.name.trim();
|
||||||
const email = values.email.trim().toLowerCase();
|
const email = values.email.trim().toLowerCase();
|
||||||
const lunchTime = values.lunchTime.trim();
|
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
|
||||||
const automaticLunch = values.automaticLunch;
|
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
|
||||||
const patch: Partial<{
|
if (ops.length === 0) return;
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
lunchTime: string;
|
|
||||||
automaticLunch: boolean;
|
|
||||||
}> = {};
|
|
||||||
if (name !== (user?.name ?? '') && name !== undefined)
|
|
||||||
patch.name = name;
|
|
||||||
if (email !== (user?.email ?? '') && email !== undefined)
|
|
||||||
patch.email = email;
|
|
||||||
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
|
|
||||||
patch.lunchTime = lunchTime;
|
|
||||||
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
|
|
||||||
patch.automaticLunch = automaticLunch;
|
|
||||||
if (Object.keys(patch).length === 0) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateUser(patch);
|
await Promise.all(ops);
|
||||||
form.reset(patch);
|
form.reset({ name, email });
|
||||||
toast.success('Profile updated successfully.');
|
toast.success('Profile updated successfully.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -102,107 +74,48 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<CardContent>
|
||||||
<CardHeader>
|
<Form {...form}>
|
||||||
<CardTitle className='text-2xl'>Account Information</CardTitle>
|
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
||||||
<CardDescription>Update your account information here.</CardDescription>
|
<FormField
|
||||||
</CardHeader>
|
control={form.control}
|
||||||
<CardContent>
|
name='name'
|
||||||
<Form {...form}>
|
render={({ field }) => (
|
||||||
<form
|
<FormItem>
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
<FormLabel>Full Name</FormLabel>
|
||||||
className='space-y-6'
|
<FormControl>
|
||||||
>
|
<Input {...field} />
|
||||||
<FormField
|
</FormControl>
|
||||||
control={form.control}
|
<FormDescription>Your public display name.</FormDescription>
|
||||||
name='name'
|
<FormMessage />
|
||||||
render={({ field }) => (
|
</FormItem>
|
||||||
<FormItem>
|
)}
|
||||||
<FormLabel>Full Name</FormLabel>
|
/>
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Your public display name.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='email'
|
name='email'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
{...field}
|
</FormControl>
|
||||||
disabled={user?.provider !== 'password'}
|
<FormDescription>
|
||||||
/>
|
Your email address associated with your account.
|
||||||
</FormControl>
|
</FormDescription>
|
||||||
<FormDescription>
|
<FormMessage />
|
||||||
Your email address associated with your account.
|
</FormItem>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className='flex justify-center mt-5'>
|
<div className='flex justify-center'>
|
||||||
<SubmitButton
|
<SubmitButton disabled={loading} pendingText='Saving...'>
|
||||||
className='lg:w-1/3 w-2/3 text-[1.0rem]'
|
Save Changes
|
||||||
disabled={loading}
|
</SubmitButton>
|
||||||
pendingText='Saving...'
|
</div>
|
||||||
>
|
</form>
|
||||||
Save Changes
|
</Form>
|
||||||
</SubmitButton>
|
</CardContent>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Checkbox,
|
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -51,7 +49,6 @@ export const StatusList = ({
|
|||||||
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
||||||
const [selectAll, setSelectAll] = useState(false);
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
const [statusInput, setStatusInput] = useState('');
|
const [statusInput, setStatusInput] = useState('');
|
||||||
const [persistStatus, setPersistStatus] = useState(false);
|
|
||||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
|
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
|
||||||
const [previousStatuses, setPreviousStatuses] = useState(statuses);
|
const [previousStatuses, setPreviousStatuses] = useState(statuses);
|
||||||
@@ -101,24 +98,11 @@ export const StatusList = ({
|
|||||||
throw new Error('Status must be between 3 & 80 characters');
|
throw new Error('Status must be between 3 & 80 characters');
|
||||||
}
|
}
|
||||||
if (selectedUserIds.length === 0 && user?.id) {
|
if (selectedUserIds.length === 0 && user?.id) {
|
||||||
await bulkCreate({
|
await bulkCreate({ message, userIds: [user.id] });
|
||||||
message,
|
|
||||||
userIds: [user.id],
|
|
||||||
persistentStatus: persistStatus
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await bulkCreate({
|
await bulkCreate({ message, userIds: selectedUserIds });
|
||||||
message,
|
|
||||||
userIds: selectedUserIds,
|
|
||||||
persistentStatus: persistStatus
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
toast.success('Status updated.');
|
toast.success('Status updated.');
|
||||||
toast.success('Status updated.', {
|
|
||||||
duration: 2000,
|
|
||||||
closeButton: true,
|
|
||||||
dismissible: true,
|
|
||||||
});
|
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
setStatusInput('');
|
setStatusInput('');
|
||||||
@@ -140,9 +124,9 @@ export const StatusList = ({
|
|||||||
|
|
||||||
const containerCn = ccn({
|
const containerCn = ccn({
|
||||||
context: tvMode,
|
context: tvMode,
|
||||||
className: 'mx-auto',
|
className: 'max-w-4xl mx-auto',
|
||||||
on: 'px-6',
|
on: 'px-6',
|
||||||
off: 'max-w-4xl px-4 sm:px-6',
|
off: 'px-4 sm:px-6',
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabsCn = ccn({
|
const tabsCn = ccn({
|
||||||
@@ -165,13 +149,13 @@ export const StatusList = ({
|
|||||||
<TabsList className={tabsCn}>
|
<TabsList className={tabsCn}>
|
||||||
<TabsTrigger value='status' className='py-3 sm:py-8'>
|
<TabsTrigger value='status' className='py-3 sm:py-8'>
|
||||||
<div className='flex items-center gap-2 sm:gap-3'>
|
<div className='flex items-center gap-2 sm:gap-3'>
|
||||||
<Activity className='text-primary sm:scale-150' />
|
<Activity className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
|
||||||
<h1 className='text-base sm:text-2xl font-bold'>Status List</h1>
|
<h1 className='text-base sm:text-2xl font-bold'>Team Status</h1>
|
||||||
</div>
|
</div>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value='history' className='py-3 sm:py-8'>
|
<TabsTrigger value='history' className='py-3 sm:py-8'>
|
||||||
<div className='flex items-center gap-2 sm:gap-3'>
|
<div className='flex items-center gap-2 sm:gap-3'>
|
||||||
<History className='text-primary sm:scale-150' />
|
<History className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
|
||||||
<h1 className='text-base sm:text-2xl font-bold'>
|
<h1 className='text-base sm:text-2xl font-bold'>
|
||||||
Status History
|
Status History
|
||||||
</h1>
|
</h1>
|
||||||
@@ -201,26 +185,22 @@ export const StatusList = ({
|
|||||||
|
|
||||||
{/* Desktop header */}
|
{/* Desktop header */}
|
||||||
<div className={headerCn}>
|
<div className={headerCn}>
|
||||||
<div className='flex w-full justify-between px-4'>
|
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
||||||
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
<span>{statuses.length} members</span>
|
||||||
<span>{statuses.length} members</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
<div className='flex items-center gap-2 text-xs'>
|
||||||
<div className='flex items-center gap-2 text-xs'>
|
<Link href='/table' className='font-medium hover:underline'>
|
||||||
<Link href='/table' className='font-medium hover:underline'>
|
Miss the old table?
|
||||||
Miss the old table?
|
</Link>
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card list */}
|
{/* Card list */}
|
||||||
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
|
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
|
||||||
{previousStatuses.map((statusData) => {
|
{statuses.map((statusData) => {
|
||||||
const { user: u, status: s } = statusData;
|
const { user: u, status: s } = statusData;
|
||||||
const isSelected = selectedUserIds.includes(u.id);
|
const isSelected = selectedUserIds.includes(u.id);
|
||||||
const isAnimating = animatingIds.has(u.id);
|
const isAnimating = animatingIds.has(u.id);
|
||||||
@@ -232,7 +212,6 @@ export const StatusList = ({
|
|||||||
className={`
|
className={`
|
||||||
relative rounded-xl border transition-all
|
relative rounded-xl border transition-all
|
||||||
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
|
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
|
||||||
${s?.persistentStatus ? 'bg-black/10' : ''}
|
|
||||||
${
|
${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
@@ -253,46 +232,43 @@ export const StatusList = ({
|
|||||||
|
|
||||||
<div className='flex items-start gap-3 sm:gap-4'>
|
<div className='flex items-start gap-3 sm:gap-4'>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className='shrink-0'>
|
<div className='flex-shrink-0'>
|
||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={u.imageUrl}
|
src={u.imageUrl}
|
||||||
fullName={u.name ?? 'User'}
|
fullName={u.name ?? 'User'}
|
||||||
className={`
|
className={`
|
||||||
transition-all duration-300
|
transition-all duration-300
|
||||||
${tvMode ? 'w-36 h-36 text-4xl' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
${tvMode ? 'w-18 h-18' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
||||||
${isAnimating ? 'ring-primary/30 ring-4' : ''}
|
${isAnimating ? 'ring-primary/30 ring-4' : ''}
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex-1 min-w-0'>
|
||||||
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
|
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
|
||||||
<h3
|
<h3
|
||||||
className={`font-semibold
|
className={`
|
||||||
${tvMode ? 'text-5xl' : 'text-base sm:text-xl'}
|
font-semibold truncate
|
||||||
|
${tvMode ? 'text-3xl' : 'text-base sm:text-xl'}
|
||||||
`}
|
`}
|
||||||
title={u.name ?? u.email ?? 'User'}
|
title={u.name ?? u.email ?? 'User'}
|
||||||
>
|
>
|
||||||
{u.name ?? u.email ?? 'User'}
|
{u.name ?? u.email ?? 'User'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{isUpdatedByOther && s?.updatedBy && (
|
{isUpdatedByOther && s?.updatedBy && (
|
||||||
<div
|
<div
|
||||||
className='hidden sm:flex items-center gap-2
|
className='hidden sm:flex items-center gap-2
|
||||||
text-muted-foreground min-w-0'
|
text-muted-foreground min-w-0'
|
||||||
>
|
>
|
||||||
<span
|
<span className='text-sm'>via</span>
|
||||||
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
|
|
||||||
>
|
|
||||||
via
|
|
||||||
</span>
|
|
||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={s.updatedBy.imageUrl}
|
src={s.updatedBy.imageUrl}
|
||||||
fullName={s.updatedBy.name ?? 'User'}
|
fullName={s.updatedBy.name ?? 'User'}
|
||||||
className={`${tvMode ? 'w-14 h-14 text-xl' : 'w-6 h-6'}`}
|
className='w-4 h-4'
|
||||||
/>
|
/>
|
||||||
<span
|
<span className='text-sm truncate'>
|
||||||
className={`${tvMode ? 'text-4xl' : 'truncate'}`}
|
|
||||||
>
|
|
||||||
{s.updatedBy.name ??
|
{s.updatedBy.name ??
|
||||||
s.updatedBy.email ??
|
s.updatedBy.email ??
|
||||||
'another user'}
|
'another user'}
|
||||||
@@ -300,67 +276,51 @@ export const StatusList = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
mb-2 sm:mb-3
|
mb-2 sm:mb-3 leading-relaxed break-words
|
||||||
${tvMode ? 'text-6xl' : 'text-[0.95rem] sm:text-lg'}
|
${tvMode ? 'text-2xl' : 'text-[0.95rem] sm:text-lg'}
|
||||||
${s ? 'text-foreground' : 'text-muted-foreground italic'}
|
${
|
||||||
|
s
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-muted-foreground italic'
|
||||||
|
}
|
||||||
|
line-clamp-2
|
||||||
`}
|
`}
|
||||||
title={s?.message ?? undefined}
|
title={s?.message ?? undefined}
|
||||||
>
|
>
|
||||||
{s?.message ?? 'No status yet.'}
|
{s?.message ?? 'No status yet.'}
|
||||||
</div>
|
</div>
|
||||||
{/* Meta - only show here when NOT in TV mode */}
|
|
||||||
{!tvMode && (
|
{/* Meta */}
|
||||||
<div className='flex items-center text-muted-foreground gap-3 sm:gap-4'>
|
<div
|
||||||
<div className='flex items-center gap-1.5'>
|
className='flex items-center gap-3 sm:gap-4
|
||||||
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
text-muted-foreground'
|
||||||
<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'>
|
|
||||||
<div className='flex items-center gap-1.5'>
|
<div className='flex items-center gap-1.5'>
|
||||||
<Clock className='w-8 h-8' />
|
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||||
<span className='text-4xl'>
|
<span className='text-xs sm:text-sm'>
|
||||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-1.5'>
|
<div className='hidden xs:flex items-center gap-1.5'>
|
||||||
<Calendar className='w-8 h-8' />
|
<Calendar className='w-4 h-4' />
|
||||||
<span className='text-4xl'>
|
<span className='text-xs sm:text-sm'>
|
||||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{s && (
|
{s && (
|
||||||
<div className='flex items-center gap-1.5'>
|
<div className='flex items-center gap-1.5'>
|
||||||
<Activity className='w-8 h-8' />
|
<Activity className='w-4 h-4' />
|
||||||
<span className='text-4xl'>
|
<span className='text-xs sm:text-sm'>
|
||||||
{getStatusAge(s.updatedAt)}
|
{getStatusAge(s.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{!tvMode && (
|
{!tvMode && (
|
||||||
<div className='flex flex-col items-end gap-2'>
|
<div className='flex flex-col items-end gap-2'>
|
||||||
@@ -381,6 +341,7 @@ export const StatusList = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile "via user" line */}
|
{/* Mobile "via user" line */}
|
||||||
{isUpdatedByOther && s?.updatedBy && (
|
{isUpdatedByOther && s?.updatedBy && (
|
||||||
<div
|
<div
|
||||||
@@ -414,29 +375,17 @@ export const StatusList = ({
|
|||||||
>
|
>
|
||||||
<CardContent className='p-6'>
|
<CardContent className='p-6'>
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<div className='flex gap-3 w-full justify-between'>
|
<div className='flex items-center gap-3'>
|
||||||
<div className='flex gap-3 items-center'>
|
<Zap className='w-5 h-5 text-primary' />
|
||||||
<Zap className='w-6 h-6 text-primary' />
|
<h3 className='text-lg font-semibold'>Update Status</h3>
|
||||||
<h3 className='text-xl font-semibold'>Update Status</h3>
|
{selectedUserIds.length > 0 && (
|
||||||
{selectedUserIds.length > 0 && (
|
<span
|
||||||
<span
|
className='px-2 py-1 bg-primary/10 text-primary
|
||||||
className='px-2 py-1 bg-primary/10 text-primary
|
text-sm rounded-full'
|
||||||
text-sm rounded-full'
|
>
|
||||||
>
|
{selectedUserIds.length} selected
|
||||||
{selectedUserIds.length} selected
|
</span>
|
||||||
</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>
|
</div>
|
||||||
<div className='flex gap-3'>
|
<div className='flex gap-3'>
|
||||||
<Input
|
<Input
|
||||||
@@ -494,7 +443,7 @@ export const StatusList = ({
|
|||||||
<div
|
<div
|
||||||
className='md:hidden fixed bottom-0 left-0 right-0 z-50
|
className='md:hidden fixed bottom-0 left-0 right-0 z-50
|
||||||
border-t bg-background/95 backdrop-blur
|
border-t bg-background/95 backdrop-blur
|
||||||
supports-backdrop-filter:bg-background/60 p-3
|
supports-[backdrop-filter]:bg-background/60 p-3
|
||||||
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
|
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-between mb-2'>
|
<div className='flex items-center justify-between mb-2'>
|
||||||
@@ -507,16 +456,6 @@ export const StatusList = ({
|
|||||||
Update your status
|
Update your status
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className='flex flex-row space-x-2'>
|
|
||||||
<Checkbox
|
|
||||||
className='border border-primary'
|
|
||||||
checked={persistStatus}
|
|
||||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
|
||||||
/>
|
|
||||||
<Label className='text-xs'>
|
|
||||||
Persist Status
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Button variant='outline' size='sm' onClick={handleSelectAll}>
|
<Button variant='outline' size='sm' onClick={handleSelectAll}>
|
||||||
{selectAll ? 'Clear' : 'Select all'}
|
{selectAll ? 'Clear' : 'Select all'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useTVMode } from '@/components/providers';
|
|||||||
import {
|
import {
|
||||||
BasedAvatar,
|
BasedAvatar,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
Input,
|
Input,
|
||||||
@@ -15,7 +17,7 @@ import {
|
|||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
||||||
import { Clock, Calendar } from 'lucide-react';
|
import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||||
import { StatusHistory } from '@/components/layout/status';
|
import { StatusHistory } from '@/components/layout/status';
|
||||||
|
|
||||||
type StatusTableProps = {
|
type StatusTableProps = {
|
||||||
@@ -118,7 +120,7 @@ export const StatusTable = ({
|
|||||||
</div>
|
</div>
|
||||||
<table className='w-full text-center rounded-md'>
|
<table className='w-full text-center rounded-md'>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className='dark:bg-muted bg-accent/30'>
|
<tr className='bg-muted'>
|
||||||
{!tvMode && (
|
{!tvMode && (
|
||||||
<th className={tCheckboxCn}>
|
<th className={tCheckboxCn}>
|
||||||
<input
|
<input
|
||||||
@@ -149,11 +151,7 @@ export const StatusTable = ({
|
|||||||
<tr
|
<tr
|
||||||
key={u.id}
|
key={u.id}
|
||||||
className={`
|
className={`
|
||||||
${
|
${i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||||
i % 2 === 0
|
|
||||||
? 'dark:bg-muted/20 bg-muted'
|
|
||||||
: 'dark:bg-muted/80 bg-accent/50'
|
|
||||||
}
|
|
||||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||||
hover:bg-muted/75 transition-all duration-300
|
hover:bg-muted/75 transition-all duration-300
|
||||||
`}
|
`}
|
||||||
@@ -173,7 +171,7 @@ export const StatusTable = ({
|
|||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={u.imageUrl}
|
src={u.imageUrl}
|
||||||
fullName={u.name}
|
fullName={u.name}
|
||||||
className={tvMode ? 'w-16 h-16 text-2xl' : 'w-12 h-12'}
|
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
|
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
|
||||||
@@ -182,7 +180,7 @@ export const StatusTable = ({
|
|||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={s.updatedBy.imageUrl}
|
src={s.updatedBy.imageUrl}
|
||||||
fullName={s.updatedBy.name}
|
fullName={s.updatedBy.name}
|
||||||
className='w-5 h-5 text-xs'
|
className='w-5 h-5'
|
||||||
/>
|
/>
|
||||||
<span className={tvMode ? 'text-xl' : 'text-base'}>
|
<span className={tvMode ? 'text-xl' : 'text-base'}>
|
||||||
Updated by {s.updatedBy.name}
|
Updated by {s.updatedBy.name}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||||
export { LunchReminder } from './lunch-reminder';
|
|
||||||
export { NotificationsPermission } from './notification-permission';
|
|
||||||
export {
|
export {
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
ThemeToggle,
|
ThemeToggle,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -11,7 +11,6 @@ export {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from './card';
|
} from './card';
|
||||||
export { Checkbox } from './checkbox';
|
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerPortal,
|
DrawerPortal,
|
||||||
@@ -55,12 +54,6 @@ export {
|
|||||||
ImageCropReset,
|
ImageCropReset,
|
||||||
} from './shadcn-io/image-crop';
|
} from './shadcn-io/image-crop';
|
||||||
export { Input } from './input';
|
export { Input } from './input';
|
||||||
export {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot,
|
|
||||||
InputOTPSeparator,
|
|
||||||
} from './input-otp';
|
|
||||||
export { Label } from './label';
|
export { Label } from './label';
|
||||||
export {
|
export {
|
||||||
Pagination,
|
Pagination,
|
||||||
@@ -76,7 +69,6 @@ export { ScrollArea, ScrollBar } from './scroll-area';
|
|||||||
export { Separator } from './separator';
|
export { Separator } from './separator';
|
||||||
export { StatusMessage } from './status-message';
|
export { StatusMessage } from './status-message';
|
||||||
export { SubmitButton } from './submit-button';
|
export { SubmitButton } from './submit-button';
|
||||||
export { Switch } from './switch';
|
|
||||||
export {
|
export {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -7,26 +7,20 @@ export const env = createEnv({
|
|||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
||||||
SITE_URL: z.url().default('http://localhost:3000'),
|
|
||||||
SENTRY_AUTH_TOKEN: z.string(),
|
SENTRY_AUTH_TOKEN: z.string(),
|
||||||
CI: z.boolean().default(true),
|
CI: z.boolean().default(true),
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
|
NEXT_PUBLIC_CONVEX_URL: z.url(),
|
||||||
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
|
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
|
||||||
NEXT_PUBLIC_CONVEX_URL: z
|
NEXT_PUBLIC_SENTRY_DSN: z.url(),
|
||||||
.url()
|
NEXT_PUBLIC_SENTRY_URL: z.url(),
|
||||||
.default('https://api.dev.convex.gbrown.org'),
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN: z
|
|
||||||
.url()
|
|
||||||
.default('https://96df775337cce23d925616dd5aea8857@sentry.gbrown.org/2'),
|
|
||||||
NEXT_PUBLIC_SENTRY_URL: z.url().default('https://sentry.gbrown.org'),
|
|
||||||
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
|
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string().default('techtracker-next'),
|
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
|
||||||
},
|
},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
||||||
SITE_URL: process.env.SITE_URL,
|
|
||||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||||
CI: process.env.CI,
|
CI: process.env.CI,
|
||||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||||
|
|||||||
@@ -3,21 +3,14 @@ import * as Sentry from '@sentry/nextjs';
|
|||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
|
||||||
integrations: [
|
|
||||||
Sentry.replayIntegration({
|
|
||||||
maskAllText: false,
|
|
||||||
blockAllMedia: false,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||||
sendDefaultPii: true,
|
sendDefaultPii: true,
|
||||||
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
||||||
tracesSampleRate: 1,
|
tracesSampleRate: 1.0,
|
||||||
enableLogs: true,
|
integrations: [Sentry.replayIntegration()],
|
||||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||||
replaysSessionSampleRate: 0.5,
|
replaysSessionSampleRate: 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
debug: false,
|
|
||||||
});
|
});
|
||||||
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
|
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
|
||||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import type { Instrumentation } from 'next';
|
import type { Instrumentation } from 'next';
|
||||||
|
|
||||||
export const register = async () => await import('../sentry.server.config');
|
export const register = async () => {
|
||||||
|
await import('../sentry.server.config');
|
||||||
|
};
|
||||||
|
|
||||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||||
Sentry.captureRequestError(...args);
|
Sentry.captureRequestError(...args);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ export const formatDate = (timestamp: Timestamp, locale = 'en-US'): string => {
|
|||||||
const date = toDate(timestamp);
|
const date = toDate(timestamp);
|
||||||
if (!date) return '--/--';
|
if (!date) return '--/--';
|
||||||
return date.toLocaleDateString(locale, {
|
return date.toLocaleDateString(locale, {
|
||||||
weekday: 'long',
|
month: 'long',
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,19 +8,16 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
|
|||||||
const isSignInPage = createRouteMatcher(['/signin']);
|
const isSignInPage = createRouteMatcher(['/signin']);
|
||||||
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
||||||
|
|
||||||
export default convexAuthNextjsMiddleware(
|
export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
|
||||||
async (request, { convexAuth }) => {
|
const banResponse = banSuspiciousIPs(request);
|
||||||
const banResponse = banSuspiciousIPs(request);
|
if (banResponse) return banResponse;
|
||||||
if (banResponse) return banResponse;
|
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
return nextjsMiddlewareRedirect(request, '/');
|
||||||
return nextjsMiddlewareRedirect(request, '/');
|
}
|
||||||
}
|
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
return nextjsMiddlewareRedirect(request, '/signin');
|
||||||
return nextjsMiddlewareRedirect(request, '/signin');
|
}
|
||||||
}
|
});
|
||||||
},
|
|
||||||
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// The following matcher runs middleware on all routes
|
// The following matcher runs middleware on all routes
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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=
|
|
||||||
@@ -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"
|
|
||||||
@@ -14,7 +14,10 @@ export const baseConfig = {
|
|||||||
'warn',
|
'warn',
|
||||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
'@typescript-eslint/require-await': 'off',
|
'@typescript-eslint/require-await': 'off',
|
||||||
'@typescript-eslint/no-misused-promises': [
|
'@typescript-eslint/no-misused-promises': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -8,23 +8,20 @@
|
|||||||
"packageManager": "bun@1.2.19",
|
"packageManager": "bun@1.2.19",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"dev:tunnel": "turbo run dev:tunnel",
|
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"clean": "turbo run clean && rm -rf node_modules",
|
"clean": "turbo run clean && rm -rf node_modules",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --cache"
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --cache"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"turbo": "^2.5.8",
|
"turbo": "^2.5.6",
|
||||||
"eslint": "^9.38.0",
|
"eslint": "^9.35.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.2",
|
||||||
"@types/node": "^20.19.23"
|
"@types/node": "^20.19.13"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@sentry/cli",
|
"@sentry/cli",
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"esbuild",
|
|
||||||
"sharp",
|
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/backend/convex/CustomPassword.ts
Normal file
18
packages/backend/convex/CustomPassword.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,8 +7,8 @@ A query function that takes two arguments looks like:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// functions.js
|
// functions.js
|
||||||
import { query } from './_generated/server';
|
import { query } from "./_generated/server";
|
||||||
import { v } from 'convex/values';
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const myQueryFunction = query({
|
export const myQueryFunction = query({
|
||||||
// Validators for arguments.
|
// Validators for arguments.
|
||||||
@@ -21,7 +21,7 @@ export const myQueryFunction = query({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Read the database as many times as you need here.
|
// Read the database as many times as you need here.
|
||||||
// See https://docs.convex.dev/database/reading-data.
|
// See https://docs.convex.dev/database/reading-data.
|
||||||
const documents = await ctx.db.query('tablename').collect();
|
const documents = await ctx.db.query("tablename").collect();
|
||||||
|
|
||||||
// Arguments passed from the client are properties of the args object.
|
// Arguments passed from the client are properties of the args object.
|
||||||
console.log(args.first, args.second);
|
console.log(args.first, args.second);
|
||||||
@@ -38,7 +38,7 @@ Using this query function in a React component looks like:
|
|||||||
```ts
|
```ts
|
||||||
const data = useQuery(api.functions.myQueryFunction, {
|
const data = useQuery(api.functions.myQueryFunction, {
|
||||||
first: 10,
|
first: 10,
|
||||||
second: 'hello',
|
second: "hello",
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ A mutation function looks like:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// functions.js
|
// functions.js
|
||||||
import { mutation } from './_generated/server';
|
import { mutation } from "./_generated/server";
|
||||||
import { v } from 'convex/values';
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const myMutationFunction = mutation({
|
export const myMutationFunction = mutation({
|
||||||
// Validators for arguments.
|
// Validators for arguments.
|
||||||
@@ -62,7 +62,7 @@ export const myMutationFunction = mutation({
|
|||||||
// Mutations can also read from the database like queries.
|
// Mutations can also read from the database like queries.
|
||||||
// See https://docs.convex.dev/database/writing-data.
|
// See https://docs.convex.dev/database/writing-data.
|
||||||
const message = { body: args.first, author: args.second };
|
const message = { body: args.first, author: args.second };
|
||||||
const id = await ctx.db.insert('messages', message);
|
const id = await ctx.db.insert("messages", message);
|
||||||
|
|
||||||
// Optionally, return a value from your mutation.
|
// Optionally, return a value from your mutation.
|
||||||
return await ctx.db.get(id);
|
return await ctx.db.get(id);
|
||||||
@@ -76,10 +76,10 @@ Using this mutation function in a React component looks like:
|
|||||||
const mutation = useMutation(api.functions.myMutationFunction);
|
const mutation = useMutation(api.functions.myMutationFunction);
|
||||||
function handleButtonPress() {
|
function handleButtonPress() {
|
||||||
// fire and forget, the most common way to use mutations
|
// fire and forget, the most common way to use mutations
|
||||||
mutation({ first: 'Hello!', second: 'me' });
|
mutation({ first: "Hello!", second: "me" });
|
||||||
// OR
|
// OR
|
||||||
// use the result once the mutation has completed
|
// use the result once the mutation has completed
|
||||||
mutation({ first: 'Hello!', second: 'me' }).then((result) =>
|
mutation({ first: "Hello!", second: "me" }).then((result) =>
|
||||||
console.log(result),
|
console.log(result),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
packages/backend/convex/_generated/api.d.ts
vendored
29
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,21 +8,17 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as auth from "../auth.js";
|
|
||||||
import type * as crons from "../crons.js";
|
|
||||||
import type * as custom_auth_index from "../custom/auth/index.js";
|
|
||||||
import type * as custom_auth_providers_entra from "../custom/auth/providers/entra.js";
|
|
||||||
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
|
||||||
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
|
||||||
import type * as files from "../files.js";
|
|
||||||
import type * as http from "../http.js";
|
|
||||||
import type * as statuses from "../statuses.js";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiFromModules,
|
ApiFromModules,
|
||||||
FilterApi,
|
FilterApi,
|
||||||
FunctionReference,
|
FunctionReference,
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
import type * as CustomPassword from "../CustomPassword.js";
|
||||||
|
import type * as auth from "../auth.js";
|
||||||
|
import type * as crons from "../crons.js";
|
||||||
|
import type * as files from "../files.js";
|
||||||
|
import type * as http from "../http.js";
|
||||||
|
import type * as statuses from "../statuses.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
@@ -33,25 +29,18 @@ import type {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
CustomPassword: typeof CustomPassword;
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
"custom/auth/index": typeof custom_auth_index;
|
|
||||||
"custom/auth/providers/entra": typeof custom_auth_providers_entra;
|
|
||||||
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
|
||||||
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
statuses: typeof statuses;
|
statuses: typeof statuses;
|
||||||
}>;
|
}>;
|
||||||
declare const fullApiWithMounts: typeof fullApi;
|
|
||||||
|
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
typeof fullApiWithMounts,
|
typeof fullApi,
|
||||||
FunctionReference<any, "public">
|
FunctionReference<any, "public">
|
||||||
>;
|
>;
|
||||||
export declare const internal: FilterApi<
|
export declare const internal: FilterApi<
|
||||||
typeof fullApiWithMounts,
|
typeof fullApi,
|
||||||
FunctionReference<any, "internal">
|
FunctionReference<any, "internal">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export declare const components: {};
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { anyApi, componentsGeneric } from "convex/server";
|
import { anyApi } from "convex/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
@@ -20,4 +20,3 @@ import { anyApi, componentsGeneric } from "convex/server";
|
|||||||
*/
|
*/
|
||||||
export const api = anyApi;
|
export const api = anyApi;
|
||||||
export const internal = anyApi;
|
export const internal = anyApi;
|
||||||
export const components = componentsGeneric();
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ActionBuilder,
|
ActionBuilder,
|
||||||
AnyComponents,
|
|
||||||
HttpActionBuilder,
|
HttpActionBuilder,
|
||||||
MutationBuilder,
|
MutationBuilder,
|
||||||
QueryBuilder,
|
QueryBuilder,
|
||||||
@@ -19,15 +18,9 @@ import {
|
|||||||
GenericQueryCtx,
|
GenericQueryCtx,
|
||||||
GenericDatabaseReader,
|
GenericDatabaseReader,
|
||||||
GenericDatabaseWriter,
|
GenericDatabaseWriter,
|
||||||
FunctionReference,
|
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
import type { DataModel } from "./dataModel.js";
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
type GenericCtx =
|
|
||||||
| GenericActionCtx<DataModel>
|
|
||||||
| GenericMutationCtx<DataModel>
|
|
||||||
| GenericQueryCtx<DataModel>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
internalActionGeneric,
|
internalActionGeneric,
|
||||||
internalMutationGeneric,
|
internalMutationGeneric,
|
||||||
internalQueryGeneric,
|
internalQueryGeneric,
|
||||||
componentsGeneric,
|
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,22 +7,11 @@ import {
|
|||||||
} from '@convex-dev/auth/server';
|
} from '@convex-dev/auth/server';
|
||||||
import { api } from './_generated/api';
|
import { api } from './_generated/api';
|
||||||
import { type Id } from './_generated/dataModel';
|
import { type Id } from './_generated/dataModel';
|
||||||
import {
|
import { action, mutation, query } from './_generated/server';
|
||||||
action,
|
import Password from './CustomPassword';
|
||||||
mutation,
|
|
||||||
query,
|
|
||||||
type MutationCtx,
|
|
||||||
type QueryCtx,
|
|
||||||
} from './_generated/server';
|
|
||||||
import Authentik from '@auth/core/providers/authentik';
|
|
||||||
import { Entra, Password, validatePassword } from './custom/auth';
|
|
||||||
|
|
||||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||||
providers: [
|
providers: [Password],
|
||||||
Authentik({ allowDangerousEmailAccountLinking: true }),
|
|
||||||
Entra,
|
|
||||||
Password,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PASSWORD_MIN = 8;
|
export const PASSWORD_MIN = 8;
|
||||||
@@ -30,120 +19,96 @@ export const PASSWORD_MAX = 100;
|
|||||||
export const PASSWORD_REGEX =
|
export const PASSWORD_REGEX =
|
||||||
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
|
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
|
||||||
|
|
||||||
type RWCtx = MutationCtx | QueryCtx;
|
export const getUser = query(async (ctx) => {
|
||||||
type User = {
|
const userId = await getAuthUserId(ctx);
|
||||||
id: Id<'users'>;
|
if (!userId) return null;
|
||||||
email: string | null;
|
|
||||||
name: string | null;
|
|
||||||
image: Id<'_storage'> | null;
|
|
||||||
lunchTime: string | null;
|
|
||||||
automaticLunch: boolean;
|
|
||||||
provider: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserData = async (ctx: RWCtx, userId: Id<'users'>): Promise<User> => {
|
|
||||||
const user = await ctx.db.get(userId);
|
const user = await ctx.db.get(userId);
|
||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
|
|
||||||
const image: Id<'_storage'> | null =
|
const image: Id<'_storage'> | null =
|
||||||
typeof user.image === 'string' && user.image.length > 0
|
typeof user.image === 'string' && user.image.length > 0
|
||||||
? (user.image as Id<'_storage'>)
|
? (user.image as Id<'_storage'>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const authAccount = await getUserAuthAccountData(ctx, userId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
name: user.name ?? null,
|
name: user.name ?? null,
|
||||||
image,
|
image,
|
||||||
lunchTime: user.lunchTime ?? null,
|
|
||||||
automaticLunch: user.automaticLunch ?? false,
|
|
||||||
provider: authAccount?.provider ?? null,
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const getUserAuthAccountData = async (ctx: RWCtx, userId: Id<'users'>) => {
|
|
||||||
const user = await ctx.db.get(userId);
|
|
||||||
if (!user) throw new ConvexError('User not found.');
|
|
||||||
const authAccountData = await ctx.db
|
|
||||||
.query('authAccounts')
|
|
||||||
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
|
|
||||||
.first();
|
|
||||||
return authAccountData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUser = query({
|
|
||||||
args: { userId: v.optional(v.id('users')) },
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const userId = args.userId ?? await getAuthUserId(ctx);
|
|
||||||
if (!userId) return null;
|
|
||||||
return getUserData(ctx, userId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getUserAuthAccount = query(async (ctx) => {
|
|
||||||
const userId = await getAuthUserId(ctx);
|
|
||||||
if (!userId) return null;
|
|
||||||
return getUserAuthAccountData(ctx, userId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAllUsers = query(async (ctx) => {
|
export const getAllUsers = query(async (ctx) => {
|
||||||
const users = await ctx.db.query('users').collect();
|
const users = await ctx.db.query('users').collect();
|
||||||
return Promise.all(users.map((u) => getUserData(ctx, u._id)));
|
return users.map((u) => ({
|
||||||
|
id: u._id,
|
||||||
|
email: u.email ?? null,
|
||||||
|
name: u.name ?? null,
|
||||||
|
image: u.image ?? null,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAllUserIds = query(async (ctx) => {
|
export const getAllUserIds = query(async (ctx) => {
|
||||||
const users = await ctx.db.query('users').collect();
|
const users = await ctx.db.query('users').collect();
|
||||||
return users.map((u) => u._id);
|
const userIds = users.map((u) => u._id);
|
||||||
|
return userIds;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUser = mutation({
|
export const updateUserName = mutation({
|
||||||
args: {
|
args: {
|
||||||
name: v.optional(v.string()),
|
name: v.string(),
|
||||||
email: v.optional(v.string()),
|
|
||||||
image: v.optional(v.id('_storage')),
|
|
||||||
lunchTime: v.optional(v.string()),
|
|
||||||
automaticLunch: v.optional(v.boolean()),
|
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, { name }) => {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
if (!userId) throw new ConvexError('Not authenticated.');
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
const user = await ctx.db.get(userId);
|
const user = await ctx.db.get(userId);
|
||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
if (args.lunchTime !== undefined && !args.lunchTime.includes(':')) {
|
await ctx.db.patch(userId, { name });
|
||||||
throw new ConvexError('Lunch time is invalid.');
|
|
||||||
}
|
|
||||||
const patch: Partial<{
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
image: Id<'_storage'>;
|
|
||||||
lunchTime: string;
|
|
||||||
automaticLunch: boolean;
|
|
||||||
}> = {};
|
|
||||||
|
|
||||||
if (args.name !== undefined) patch.name = args.name;
|
|
||||||
if (args.email !== undefined) patch.email = args.email;
|
|
||||||
if (args.lunchTime !== undefined) patch.lunchTime = args.lunchTime;
|
|
||||||
if (args.automaticLunch !== undefined)
|
|
||||||
patch.automaticLunch = args.automaticLunch;
|
|
||||||
|
|
||||||
if (args.image !== undefined) {
|
|
||||||
const oldImage = user.image as Id<'_storage'> | undefined;
|
|
||||||
patch.image = args.image;
|
|
||||||
if (oldImage && oldImage !== args.image) {
|
|
||||||
await ctx.storage.delete(oldImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(patch).length > 0) {
|
|
||||||
await ctx.db.patch(userId, patch);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateUserEmail = mutation({
|
||||||
|
args: {
|
||||||
|
email: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { email }) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
|
const user = await ctx.db.get(userId);
|
||||||
|
if (!user) throw new ConvexError('User not found.');
|
||||||
|
await ctx.db.patch(userId, { email });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateUserImage = mutation({
|
||||||
|
args: {
|
||||||
|
storageId: v.id('_storage'),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { storageId }) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
|
const user = await ctx.db.get(userId);
|
||||||
|
if (!user) throw new ConvexError('User not found.');
|
||||||
|
const oldImage = user.image as Id<'_storage'> | undefined;
|
||||||
|
await ctx.db.patch(userId, { image: storageId });
|
||||||
|
if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const validatePassword = (password: string): boolean => {
|
||||||
|
if (
|
||||||
|
password.length < 8 ||
|
||||||
|
password.length > 100 ||
|
||||||
|
!/\d/.test(password) ||
|
||||||
|
!/[a-z]/.test(password) ||
|
||||||
|
!/[A-Z]/.test(password)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateUserPassword = action({
|
export const updateUserPassword = action({
|
||||||
args: {
|
args: {
|
||||||
currentPassword: v.string(),
|
currentPassword: v.string(),
|
||||||
@@ -152,7 +117,7 @@ export const updateUserPassword = action({
|
|||||||
handler: async (ctx, { currentPassword, newPassword }) => {
|
handler: async (ctx, { currentPassword, newPassword }) => {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
if (!userId) throw new ConvexError('Not authenticated.');
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
const user = await ctx.runQuery(api.auth.getUser, { userId });
|
const user = await ctx.runQuery(api.auth.getUser);
|
||||||
if (!user?.email) throw new ConvexError('User not found.');
|
if (!user?.email) throw new ConvexError('User not found.');
|
||||||
const verified = await retrieveAccount(ctx, {
|
const verified = await retrieveAccount(ctx, {
|
||||||
provider: 'password',
|
provider: 'password',
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
|
// convex/crons.ts
|
||||||
import { cronJobs } from 'convex/server';
|
import { cronJobs } from 'convex/server';
|
||||||
import { api } from './_generated/api';
|
import { api } from './_generated/api';
|
||||||
|
|
||||||
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
|
|
||||||
const crons = cronJobs();
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
// Runs at 5:00 PM America/Chicago, Monday–Friday.
|
||||||
|
// Convex will handle DST if your project version supports `timeZone`.
|
||||||
crons.cron(
|
crons.cron(
|
||||||
// Run at 7:00 AM CST / 8:00 AM CDT
|
|
||||||
// Only on weekdays
|
|
||||||
'Schedule Automatic Lunches',
|
|
||||||
'0 13 * * 1-5',
|
|
||||||
api.statuses.automaticLunch,
|
|
||||||
);
|
|
||||||
|
|
||||||
crons.cron(
|
|
||||||
// Run at 4:00 PM CST / 5:00 PM CDT
|
|
||||||
// Only on weekdays
|
|
||||||
'End of shift (weekdays 5pm CT)',
|
'End of shift (weekdays 5pm CT)',
|
||||||
'0 22 * * 1-5',
|
'0 22 * * 1-5',
|
||||||
api.statuses.endOfShiftUpdate,
|
api.statuses.endOfShiftUpdate,
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export { Entra } from './providers/entra';
|
|
||||||
export { Password, validatePassword } from './providers/password';
|
|
||||||
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineSchema, defineTable } from 'convex/server';
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
import { v, VId } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { authTables } from '@convex-dev/auth/server';
|
import { authTables } from '@convex-dev/auth/server';
|
||||||
|
|
||||||
// The schema is normally optional, but Convex Auth
|
// The schema is normally optional, but Convex Auth
|
||||||
@@ -12,8 +12,6 @@ export default defineSchema({
|
|||||||
image: v.optional(v.string()),
|
image: v.optional(v.string()),
|
||||||
email: v.optional(v.string()),
|
email: v.optional(v.string()),
|
||||||
currentStatusId: v.optional(v.id('statuses')),
|
currentStatusId: v.optional(v.id('statuses')),
|
||||||
lunchTime: v.optional(v.string()),
|
|
||||||
automaticLunch: v.optional(v.boolean()),
|
|
||||||
emailVerificationTime: v.optional(v.number()),
|
emailVerificationTime: v.optional(v.number()),
|
||||||
phone: v.optional(v.string()),
|
phone: v.optional(v.string()),
|
||||||
phoneVerificationTime: v.optional(v.number()),
|
phoneVerificationTime: v.optional(v.number()),
|
||||||
@@ -26,7 +24,6 @@ export default defineSchema({
|
|||||||
message: v.string(),
|
message: v.string(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
updatedBy: v.optional(v.id('users')),
|
updatedBy: v.optional(v.id('users')),
|
||||||
persistentStatus: v.optional(v.boolean()),
|
|
||||||
})
|
})
|
||||||
.index('by_user', ['userId'])
|
.index('by_user', ['userId'])
|
||||||
.index('by_user_updatedAt', ['userId', 'updatedAt']),
|
.index('by_user_updatedAt', ['userId', 'updatedAt']),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type MutationCtx,
|
type MutationCtx,
|
||||||
type QueryCtx,
|
type QueryCtx,
|
||||||
action,
|
action,
|
||||||
|
internalMutation,
|
||||||
mutation,
|
mutation,
|
||||||
query,
|
query,
|
||||||
} from './_generated/server';
|
} from './_generated/server';
|
||||||
@@ -12,6 +13,7 @@ import type { Doc, Id } from './_generated/dataModel';
|
|||||||
import { paginationOptsValidator } from 'convex/server';
|
import { paginationOptsValidator } from 'convex/server';
|
||||||
|
|
||||||
type RWCtx = MutationCtx | QueryCtx;
|
type RWCtx = MutationCtx | QueryCtx;
|
||||||
|
|
||||||
type StatusRow = {
|
type StatusRow = {
|
||||||
user: {
|
user: {
|
||||||
id: Id<'users'>;
|
id: Id<'users'>;
|
||||||
@@ -24,7 +26,6 @@ type StatusRow = {
|
|||||||
message: string;
|
message: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
updatedBy: StatusRow['user'] | null;
|
updatedBy: StatusRow['user'] | null;
|
||||||
persistentStatus: boolean;
|
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,73 +35,72 @@ type Paginated<T> = {
|
|||||||
continueCursor: string | null;
|
continueCursor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CHANGED: typed helpers
|
||||||
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
|
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
|
||||||
const user = await ctx.db.get(userId);
|
const user = await ctx.db.get(userId);
|
||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getName = (u: Doc<'users'>): string | null =>
|
const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
|
||||||
'name' in u && typeof u.name === 'string' ? u.name : null;
|
const [latest] = await ctx.db
|
||||||
|
.query('statuses')
|
||||||
const getEmail = (u: Doc<'users'>): string | null =>
|
.withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
|
||||||
'email' in u && typeof u.email === 'string' ? u.email : null;
|
.order('desc')
|
||||||
|
.take(1);
|
||||||
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => {
|
return latest as Doc<'statuses'> | null;
|
||||||
if (!('image' in u)) return null;
|
|
||||||
const img = (u as { image?: unknown }).image as string | undefined;
|
|
||||||
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new status for a single user.
|
||||||
|
* - Defaults userId to the caller.
|
||||||
|
* - updatedBy defaults to the caller.
|
||||||
|
* - Updates the user's currentStatusId pointer.
|
||||||
|
*/
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
message: v.string(),
|
message: v.string(),
|
||||||
userId: v.optional(v.id('users')),
|
userId: v.optional(v.id('users')),
|
||||||
updatedBy: v.optional(v.id('users')),
|
updatedBy: v.optional(v.id('users')),
|
||||||
persistentStatus: v.optional(v.boolean()),
|
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const authUserId: Id<'users'> | null = await getAuthUserId(ctx);
|
const authUserId = await getAuthUserId(ctx);
|
||||||
if (!args.userId && !authUserId) {
|
if (!authUserId) throw new ConvexError('Not authenticated.');
|
||||||
throw new ConvexError('Not authenticated.');
|
|
||||||
}
|
const userId = args.userId ?? authUserId;
|
||||||
|
await ensureUser(ctx, userId);
|
||||||
|
|
||||||
|
const updatedBy = args.updatedBy ?? authUserId;
|
||||||
|
await ensureUser(ctx, updatedBy);
|
||||||
|
|
||||||
const message = args.message.trim();
|
const message = args.message.trim();
|
||||||
if (message.length === 0) {
|
if (message.length === 0) {
|
||||||
throw new ConvexError('Message cannot be empty.');
|
throw new ConvexError('Message cannot be empty.');
|
||||||
}
|
}
|
||||||
const userId = args.userId ?? authUserId!;
|
|
||||||
const updatedBy = args.updatedBy ?? authUserId;
|
const statusId = await ctx.db.insert('statuses', {
|
||||||
const persistentStatus = args.persistentStatus ?? false;
|
message,
|
||||||
await ensureUser(ctx, userId);
|
userId,
|
||||||
let statusId: Id<'statuses'>;
|
updatedBy,
|
||||||
if (updatedBy) {
|
updatedAt: Date.now(),
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await ctx.db.patch(userId, { currentStatusId: statusId });
|
await ctx.db.patch(userId, { currentStatusId: statusId });
|
||||||
|
|
||||||
return { statusId };
|
return { statusId };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk create the same status for many users.
|
||||||
|
* - updatedBy defaults to the caller.
|
||||||
|
* - Updates each user's currentStatusId pointer.
|
||||||
|
*/
|
||||||
export const bulkCreate = mutation({
|
export const bulkCreate = mutation({
|
||||||
args: {
|
args: {
|
||||||
message: v.string(),
|
message: v.string(),
|
||||||
userIds: v.array(v.id('users')),
|
userIds: v.array(v.id('users')),
|
||||||
updatedBy: v.optional(v.id('users')),
|
updatedBy: v.optional(v.id('users')),
|
||||||
persistentStatus: v.optional(v.boolean()),
|
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const authUserId = await getAuthUserId(ctx);
|
const authUserId = await getAuthUserId(ctx);
|
||||||
@@ -109,7 +109,6 @@ export const bulkCreate = mutation({
|
|||||||
if (args.userIds.length === 0) return { statusIds: [] };
|
if (args.userIds.length === 0) return { statusIds: [] };
|
||||||
|
|
||||||
const updatedBy = args.updatedBy ?? authUserId;
|
const updatedBy = args.updatedBy ?? authUserId;
|
||||||
const persistentStatus = args.persistentStatus ?? false;
|
|
||||||
await ensureUser(ctx, updatedBy);
|
await ensureUser(ctx, updatedBy);
|
||||||
|
|
||||||
const message = args.message.trim();
|
const message = args.message.trim();
|
||||||
@@ -120,130 +119,91 @@ export const bulkCreate = mutation({
|
|||||||
const statusIds: Id<'statuses'>[] = [];
|
const statusIds: Id<'statuses'>[] = [];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Sequential to keep load predictable; switch to Promise.all
|
||||||
|
// if your ownerIds lists are small and bounded.
|
||||||
for (const userId of args.userIds) {
|
for (const userId of args.userIds) {
|
||||||
await ensureUser(ctx, userId);
|
await ensureUser(ctx, userId);
|
||||||
|
|
||||||
const statusId = await ctx.db.insert('statuses', {
|
const statusId = await ctx.db.insert('statuses', {
|
||||||
message,
|
message,
|
||||||
userId,
|
userId,
|
||||||
updatedBy,
|
updatedBy,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
persistentStatus,
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(userId, { currentStatusId: statusId });
|
||||||
|
statusIds.push(statusId);
|
||||||
|
}
|
||||||
|
return { statusIds };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all statuses for all users.
|
||||||
|
*/
|
||||||
|
export const updateAllStatuses = mutation({
|
||||||
|
args: { message: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userIds = await ctx.runQuery(api.auth.getAllUserIds);
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
const statusIds: Id<'statuses'>[] = [];
|
||||||
|
for (const userId of userIds) {
|
||||||
|
await ensureUser(ctx, userId);
|
||||||
|
const statusId = await ctx.db.insert('statuses', {
|
||||||
|
message: args.message,
|
||||||
|
userId,
|
||||||
|
updatedAt,
|
||||||
});
|
});
|
||||||
await ctx.db.patch(userId, { currentStatusId: statusId });
|
await ctx.db.patch(userId, { currentStatusId: statusId });
|
||||||
statusIds.push(statusId);
|
statusIds.push(statusId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { statusIds };
|
return { statusIds };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateAllStatuses = mutation({
|
/**
|
||||||
args: {
|
* Current status for a specific user.
|
||||||
message: v.string(),
|
* - Uses users.currentStatusId if present,
|
||||||
persistentStatus: v.optional(v.boolean())
|
* otherwise falls back to latest by index.
|
||||||
},
|
*/
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const users = await ctx.db.query('users').collect();
|
|
||||||
const message = args.message.trim();
|
|
||||||
const persistentStatus = args.persistentStatus ?? false;
|
|
||||||
if (message.length === 0) {
|
|
||||||
throw new ConvexError('Message cannot be empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIds: Id<'statuses'>[] = [];
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
|
|
||||||
userId: user._id,
|
|
||||||
});
|
|
||||||
if (!curStatus?.persistentStatus) {
|
|
||||||
const statusId = await ctx.db.insert('statuses', {
|
|
||||||
message,
|
|
||||||
userId: user._id,
|
|
||||||
updatedAt: now,
|
|
||||||
persistentStatus,
|
|
||||||
});
|
|
||||||
await ctx.db.patch(user._id, { currentStatusId: statusId });
|
|
||||||
statusIds.push(statusId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { statusIds };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createLunchStatus = mutation({
|
|
||||||
args: { userId: v.optional(v.id('users'))},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const authUserId = await getAuthUserId(ctx);
|
|
||||||
const lunchUserId = args.userId ?? authUserId
|
|
||||||
if (!lunchUserId) throw new ConvexError('Not authenticated.');
|
|
||||||
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
|
|
||||||
userId: lunchUserId,
|
|
||||||
});
|
|
||||||
if (curStatus?.persistentStatus) {
|
|
||||||
return { success: false, error: 'Current status is persistent.'};
|
|
||||||
}
|
|
||||||
await ctx.runMutation(api.statuses.create, {
|
|
||||||
message: 'At lunch',
|
|
||||||
userId: lunchUserId,
|
|
||||||
});
|
|
||||||
const oneHour = 60 * 60 * 1000;
|
|
||||||
await ctx.scheduler.runAfter(oneHour, api.statuses.backFromLunchStatus, {
|
|
||||||
userId: lunchUserId,
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const backFromLunchStatus = mutation({
|
|
||||||
args: { userId: v.optional(v.id('users')) },
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const authUserId = await getAuthUserId(ctx);
|
|
||||||
const lunchUserId = args.userId ?? authUserId
|
|
||||||
if (!lunchUserId) throw new ConvexError('Not authenticated.');
|
|
||||||
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
|
|
||||||
userId: lunchUserId,
|
|
||||||
});
|
|
||||||
if (curStatus?.persistentStatus) {
|
|
||||||
return { success: false, error: 'Current status is persistent.'};
|
|
||||||
}
|
|
||||||
const user = await ensureUser(ctx, lunchUserId);
|
|
||||||
if (!user.currentStatusId) throw new ConvexError('User has no current status.');
|
|
||||||
const currentStatus = await ctx.db.get(user.currentStatusId);
|
|
||||||
if (currentStatus?.message === 'At lunch') {
|
|
||||||
await ctx.runMutation(api.statuses.create, {
|
|
||||||
message: 'At desk',
|
|
||||||
userId: lunchUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getCurrentForUser = query({
|
export const getCurrentForUser = query({
|
||||||
args: { userId: v.id('users') },
|
args: { userId: v.id('users') },
|
||||||
handler: async (ctx, { userId }) => {
|
handler: async (ctx, { userId }) => {
|
||||||
const user = await ensureUser(ctx, userId);
|
const user = await ensureUser(ctx, userId);
|
||||||
|
|
||||||
if (user.currentStatusId) {
|
if (user.currentStatusId) {
|
||||||
const status = await ctx.db.get(user.currentStatusId);
|
const status = await ctx.db.get(user.currentStatusId);
|
||||||
if (status) return status;
|
if (status) return status;
|
||||||
}
|
}
|
||||||
const [latest] = await ctx.db
|
|
||||||
.query('statuses')
|
|
||||||
.withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
|
|
||||||
.order('desc')
|
|
||||||
.take(1);
|
|
||||||
|
|
||||||
return latest ?? null;
|
return await latestStatusForOwner(ctx, userId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getName = (u: Doc<'users'>): string | null =>
|
||||||
|
'name' in u && typeof u.name === 'string' ? u.name : null;
|
||||||
|
const getEmail = (u: Doc<'users'>): string | null =>
|
||||||
|
'email' in u && typeof u.email === 'string' ? u.email : null;
|
||||||
|
|
||||||
|
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => {
|
||||||
|
if (!('image' in u)) return null;
|
||||||
|
const img = (u as { image?: unknown }).image as string | undefined;
|
||||||
|
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current statuses for all users.
|
||||||
|
* - Reads each user's currentStatusId pointer.
|
||||||
|
* - Falls back to latest-by-index if pointer is missing.
|
||||||
|
*/
|
||||||
export const getCurrentForAll = query({
|
export const getCurrentForAll = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx): Promise<StatusRow[]> => {
|
handler: async (ctx): Promise<StatusRow[]> => {
|
||||||
const users = await ctx.db.query('users').collect();
|
const users = await ctx.db.query('users').collect();
|
||||||
|
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
users.map(async (u) => {
|
users.map(async (u) => {
|
||||||
|
// Resolve user's current or latest status
|
||||||
let curStatus: Doc<'statuses'> | null = null;
|
let curStatus: Doc<'statuses'> | null = null;
|
||||||
if ('currentStatusId' in u && u.currentStatusId) {
|
if ('currentStatusId' in u && u.currentStatusId) {
|
||||||
curStatus = await ctx.db.get(u.currentStatusId);
|
curStatus = await ctx.db.get(u.currentStatusId);
|
||||||
@@ -256,35 +216,39 @@ export const getCurrentForAll = query({
|
|||||||
.take(1);
|
.take(1);
|
||||||
curStatus = latest ?? null;
|
curStatus = latest ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User display + URL
|
||||||
const userImageId = getImageId(u);
|
const userImageId = getImageId(u);
|
||||||
const userImageUrl = userImageId
|
const userImageUrl = userImageId
|
||||||
? await ctx.storage.getUrl(userImageId)
|
? await ctx.storage.getUrl(userImageId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Updated by (if different) + URL
|
||||||
let updatedByUser: StatusRow['user'] | null = null;
|
let updatedByUser: StatusRow['user'] | null = null;
|
||||||
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
|
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
|
||||||
const updater = await ctx.db.get(curStatus.updatedBy);
|
const updater = await ctx.db.get(curStatus.updatedBy);
|
||||||
if (updater) {
|
if (!updater) throw new ConvexError('Updater not found.');
|
||||||
const updaterImageId = getImageId(updater);
|
const updaterImageId = getImageId(updater);
|
||||||
const updaterImageUrl = updaterImageId
|
const updaterImageUrl = updaterImageId
|
||||||
? await ctx.storage.getUrl(updaterImageId)
|
? await ctx.storage.getUrl(updaterImageId)
|
||||||
: null;
|
: null;
|
||||||
updatedByUser = {
|
updatedByUser = {
|
||||||
id: updater._id,
|
id: updater._id,
|
||||||
email: getEmail(updater),
|
email: getEmail(updater),
|
||||||
name: getName(updater),
|
name: getName(updater),
|
||||||
imageUrl: updaterImageUrl,
|
imageUrl: updaterImageUrl,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const status: StatusRow['status'] = curStatus
|
const status: StatusRow['status'] = curStatus
|
||||||
? {
|
? {
|
||||||
id: curStatus._id,
|
id: curStatus._id,
|
||||||
message: curStatus.message,
|
message: curStatus.message,
|
||||||
updatedAt: curStatus.updatedAt,
|
updatedAt: curStatus.updatedAt,
|
||||||
updatedBy: updatedByUser,
|
updatedBy: updatedByUser,
|
||||||
persistentStatus: curStatus.persistentStatus ?? false,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: u._id,
|
id: u._id,
|
||||||
@@ -299,7 +263,9 @@ export const getCurrentForAll = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paginated history
|
/**
|
||||||
|
* Paginated history for all users or for a specific user.
|
||||||
|
*/
|
||||||
export const listHistory = query({
|
export const listHistory = query({
|
||||||
args: {
|
args: {
|
||||||
userId: v.optional(v.id('users')),
|
userId: v.optional(v.id('users')),
|
||||||
@@ -309,6 +275,7 @@ export const listHistory = query({
|
|||||||
ctx,
|
ctx,
|
||||||
{ userId, paginationOpts },
|
{ userId, paginationOpts },
|
||||||
): Promise<Paginated<StatusRow>> => {
|
): Promise<Paginated<StatusRow>> => {
|
||||||
|
// Query statuses newest-first, optionally filtered by user
|
||||||
const result = userId
|
const result = userId
|
||||||
? await ctx.db
|
? await ctx.db
|
||||||
.query('statuses')
|
.query('statuses')
|
||||||
@@ -316,15 +283,21 @@ export const listHistory = query({
|
|||||||
.order('desc')
|
.order('desc')
|
||||||
.paginate(paginationOpts)
|
.paginate(paginationOpts)
|
||||||
: await ctx.db.query('statuses').order('desc').paginate(paginationOpts);
|
: await ctx.db.query('statuses').order('desc').paginate(paginationOpts);
|
||||||
|
|
||||||
|
// Cache user display objects to avoid refetching repeatedly
|
||||||
const displayCache = new Map<string, StatusRow['user']>();
|
const displayCache = new Map<string, StatusRow['user']>();
|
||||||
|
|
||||||
const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
|
const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
|
||||||
const key = uid as unknown as string;
|
const key = uid as unknown as string;
|
||||||
const cached = displayCache.get(key);
|
const cached = displayCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const user = await ctx.db.get(uid);
|
const user = await ctx.db.get(uid);
|
||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
|
|
||||||
const imgId = getImageId(user);
|
const imgId = getImageId(user);
|
||||||
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
|
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
|
||||||
|
|
||||||
const display: StatusRow['user'] = {
|
const display: StatusRow['user'] = {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
email: getEmail(user),
|
email: getEmail(user),
|
||||||
@@ -334,6 +307,7 @@ export const listHistory = query({
|
|||||||
displayCache.set(key, display);
|
displayCache.set(key, display);
|
||||||
return display;
|
return display;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statuses: StatusRow[] = [];
|
const statuses: StatusRow[] = [];
|
||||||
for (const s of result.page) {
|
for (const s of result.page) {
|
||||||
const owner = await getDisplay(s.userId);
|
const owner = await getDisplay(s.userId);
|
||||||
@@ -341,6 +315,7 @@ export const listHistory = query({
|
|||||||
s.updatedBy && s.updatedBy !== s.userId
|
s.updatedBy && s.updatedBy !== s.userId
|
||||||
? await getDisplay(s.updatedBy)
|
? await getDisplay(s.updatedBy)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
statuses.push({
|
statuses.push({
|
||||||
user: owner,
|
user: owner,
|
||||||
status: {
|
status: {
|
||||||
@@ -348,12 +323,15 @@ export const listHistory = query({
|
|||||||
message: s.message,
|
message: s.message,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
updatedBy,
|
updatedBy,
|
||||||
persistentStatus: s.persistentStatus ?? false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const page = statuses.sort(
|
||||||
|
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: statuses,
|
page,
|
||||||
isDone: result.isDone,
|
isDone: result.isDone,
|
||||||
continueCursor: result.continueCursor,
|
continueCursor: result.continueCursor,
|
||||||
};
|
};
|
||||||
@@ -367,49 +345,17 @@ export const endOfShiftUpdate = action({
|
|||||||
timeZone: 'America/Chicago',
|
timeZone: 'America/Chicago',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const day = now.getDay(),
|
const day = now.getDay();
|
||||||
hour = now.getHours(),
|
const hour = now.getHours();
|
||||||
minute = now.getMinutes();
|
const minute = now.getMinutes();
|
||||||
if (day == 0 || day === 6) return;
|
if (day == 0 || day === 6) return;
|
||||||
const message = day === 5 ? 'Enjoying the weekend' : 'End of shift';
|
if (hour === 5) {
|
||||||
if (hour === 17) {
|
await ctx.runMutation(api.statuses.updateAllStatuses, {
|
||||||
await ctx.runMutation(api.statuses.updateAllStatuses, { message });
|
message: 'End of shift',
|
||||||
} else if (hour === 16) {
|
});
|
||||||
|
} else if (hour === 4) {
|
||||||
const ms = ((60 - minute) % 60) * 60 * 1000;
|
const ms = ((60 - minute) % 60) * 60 * 1000;
|
||||||
await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate);
|
await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate);
|
||||||
} else return;
|
} else return;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const automaticLunch = action({
|
|
||||||
handler: async (ctx) => {
|
|
||||||
const now = new Date(
|
|
||||||
new Date().toLocaleString('en-US', {
|
|
||||||
timeZone: 'America/Chicago',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const users = await ctx.runQuery(api.auth.getAllUsers);
|
|
||||||
await Promise.all(
|
|
||||||
users.map(async (user) => {
|
|
||||||
if (user.automaticLunch && user.lunchTime) {
|
|
||||||
const [hours, minutes] = user.lunchTime.split(':').map(Number);
|
|
||||||
const userLunchTime = new Date(now);
|
|
||||||
userLunchTime.setHours(hours, minutes, 0, 0);
|
|
||||||
const diffInMs = userLunchTime.getTime() - now.getTime();
|
|
||||||
// Only schedule if lunch is in the future today
|
|
||||||
if (diffInMs > 0) {
|
|
||||||
await ctx.scheduler.runAfter(
|
|
||||||
diffInMs,
|
|
||||||
api.statuses.createLunchStatus,
|
|
||||||
{ userId: user.id },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`Skipped ${user.name} - lunch time ${user.lunchTime} already passed.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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=
|
|
||||||
@@ -4,23 +4,15 @@
|
|||||||
"description": "Convex Backend for Tech Tracker",
|
"description": "Convex Backend for Tech Tracker",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "convex dev",
|
"dev": "convex dev",
|
||||||
"dev:tunnel": "convex dev",
|
|
||||||
"predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard",
|
"predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard",
|
||||||
"setup": "convex dev --until-success"
|
"setup": "convex dev --until-success"
|
||||||
},
|
},
|
||||||
"author": "Gib",
|
"author": "Gib",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"convex": "^1.27.0"
|
||||||
"@react-email/components": "0.5.4",
|
|
||||||
"@react-email/render": "^1.4.0",
|
|
||||||
"convex": "^1.28.0",
|
|
||||||
"react": "19.1.1",
|
|
||||||
"react-dom": "19.1.1",
|
|
||||||
"usesend-js": "^1.5.6"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"react-email": "4.2.11",
|
|
||||||
"typescript": "5.9.2"
|
"typescript": "5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,6 @@
|
|||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"dev:tunnel": {
|
|
||||||
"cache": false,
|
|
||||||
"persistent": true
|
|
||||||
},
|
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"cache": false
|
||||||
|
|||||||
Reference in New Issue
Block a user