Compare commits
2 Commits
main
...
d116623c80
| Author | SHA1 | Date | |
|---|---|---|---|
| d116623c80 | |||
| e68638ec9c |
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Hosting
|
||||
/docker/data
|
||||
/host/convex/docker/data
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
47
README.md
@@ -31,30 +31,29 @@ I would recommend using [bun](https://bun.sh/) to install dependencies.
|
||||
bun i
|
||||
```
|
||||
|
||||
You will also need docker installed on whatever host you plan to run the Convex instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Convex SaaS if you want to have a much easier time, probably. I wouldn't know!
|
||||
You will also need docker installed on whatever host you plan to run the Supabase instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Supabase SaaS if you want to have a much easier time, probably. I wouldn't know!
|
||||
|
||||
### Add your environment variables
|
||||
|
||||
Copy the example environment variable files and paste them in the same directory named `.env`.
|
||||
|
||||
Environment variables for Next Application
|
||||
|
||||
```bash
|
||||
cp ./apps/next/env.example ./apps/next/.env
|
||||
cp ./app/next/env.example ./app/next/.env
|
||||
```
|
||||
|
||||
Environment variables for Self Hosting Convex & Website with Docker
|
||||
|
||||
```bash
|
||||
cp ./docker/env.example ./docker/.env
|
||||
cp ./host/convex/docker/env.example ./host/convex/docker/.env
|
||||
```
|
||||
|
||||
### Start self hosted convex & Next Web Application
|
||||
```bash
|
||||
cp ./host/next/docker/env.example ./host/next/docker/.env
|
||||
```
|
||||
|
||||
### Start self hosted convex
|
||||
|
||||
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
|
||||
|
||||
```bash
|
||||
cd ./docker
|
||||
cd ./host/convex/docker
|
||||
sudo docker compose up -d
|
||||
sudo docker compose exec convex-backend ./generate_admin_key.sh
|
||||
```
|
||||
@@ -63,10 +62,36 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
|
||||
|
||||
Run
|
||||
|
||||
```bash
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
to start your development environment with turbopack
|
||||
|
||||
You can also run
|
||||
|
||||
```bash
|
||||
bun dev:slow
|
||||
```
|
||||
|
||||
to start your development environment with webpack (This is for the next app.)
|
||||
|
||||
### Start your Production Environment.
|
||||
|
||||
There are Dockerfiles & docker compose files that can be found in the `./scripts/docker` folder for the Next.js website. There is also a script called `reload_container` located in the `./scripts/` folder which was created to quickly update the container, but this will give you a better idea of what you need to do. First, build the image with
|
||||
|
||||
```bash
|
||||
sudo docker compose -f ./host/next/docker/compose.yml build
|
||||
```
|
||||
|
||||
then you can run the container with
|
||||
|
||||
```bash
|
||||
sudo docker compose -f ./host/next/docker/compose up -d
|
||||
```
|
||||
|
||||
Now, you may end up with some build errors. The `reload_containers` script swaps out the next config before it runs the docker build to skip any build errors, so you may want to do this as well, though you are welcome to fix the build errors as well, of course!
|
||||
|
||||
### Fin
|
||||
|
||||
I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here!
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Tech Tracker",
|
||||
"owner": "gib",
|
||||
"name": "techtracker-expo",
|
||||
"slug": "techtracker-expo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "org.gbrown.techtrackerexpo",
|
||||
"scheme": "techtrackerexpo",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#2e2f3d"
|
||||
},
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"usesAppleSignIn": true,
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.gbrown.techtracker",
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSLocationWhenInUseUsageDescription": "This app uses your location in order to allow you to share your location in chat.",
|
||||
"NSCameraUsageDescription": "This app uses your camera to take photos & send them in the chat."
|
||||
}
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#2e2f3d"
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"permissions": [
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.INTERNET"
|
||||
],
|
||||
"package": "com.gbrown.techtracker"
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
@@ -47,7 +27,6 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-apple-authentication",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -59,27 +38,6 @@
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-secure-store",
|
||||
{
|
||||
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your FaceID biometric data."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
|
||||
}
|
||||
],
|
||||
[
|
||||
"@sentry/react-native/expo",
|
||||
{
|
||||
"url": "https://sentry.gbrown.org",
|
||||
"note": "Use SENTRY_AUTH_TOKEN env to authenticate with Sentry.",
|
||||
"project": "tech-tracker-expo",
|
||||
"organization": "gib"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
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",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"dev": "expo start",
|
||||
"dev:tunnel": "expo start --tunnel",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.6.0",
|
||||
"@react-navigation/elements": "^2.7.1",
|
||||
"@react-navigation/native": "^7.1.19",
|
||||
"@sentry/react-native": "^7.4.0",
|
||||
"expo": "~54.0.20",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.4",
|
||||
"expo-constants": "~18.0.8",
|
||||
"expo-font": "~14.0.8",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image": "~3.0.8",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-router": "~6.0.2",
|
||||
"expo-splash-screen": "~31.0.9",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.3",
|
||||
"react-native-safe-area-context": "~5.6.1",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.2",
|
||||
"react-native-worklets": "0.5.1"
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.17",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint-config-expo": "~10.0.0"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -15,24 +15,19 @@ export default function TabLayout() {
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name='house.fill' color={color} />
|
||||
),
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name='explore'
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name='paperplane.fill' color={color} />
|
||||
),
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
@@ -15,82 +16,71 @@ export default function TabTwoScreen() {
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color='#808080'
|
||||
name='chevron.left.forwardslash.chevron.right'
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type='title'
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>
|
||||
This app includes example code to help you get started.
|
||||
</ThemedText>
|
||||
<Collapsible title='File-based routing'>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
||||
and{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/explore.tsx</ThemedText>
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href='https://docs.expo.dev/router/introduction'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Android, iOS, and web support'>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the
|
||||
web version, press <ThemedText type='defaultSemiBold'>w</ThemedText>{' '}
|
||||
in the terminal running this project.
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title='Images'>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the{' '}
|
||||
<ThemedText type='defaultSemiBold'>@2x</ThemedText> and{' '}
|
||||
<ThemedText type='defaultSemiBold'>@3x</ThemedText> suffixes to
|
||||
provide files for different screen densities
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('assets/images/react-logo.png')}
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href='https://reactnative.dev/docs/images'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Light and dark mode components'>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type='defaultSemiBold'>useColorScheme()</ThemedText> hook
|
||||
lets you inspect what the user's current color scheme is, and so
|
||||
you can adjust UI colors accordingly.
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href='https://docs.expo.dev/develop/user-interface/color-themes/'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Animations'>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
components/HelloWave.tsx
|
||||
</ThemedText>{' '}
|
||||
component uses the powerful{' '}
|
||||
<ThemedText type='defaultSemiBold' style={{ fontFamily: Fonts.mono }}>
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
@@ -98,10 +88,7 @@ export default function TabTwoScreen() {
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
components/ParallaxScrollView.tsx
|
||||
</ThemedText>{' '}
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
@@ -12,22 +13,20 @@ export default function HomeScreen() {
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('assets/images/partial-react-logo.png')}
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}
|
||||
>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type='title'>Welcome!</ThemedText>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type='subtitle'>Step 1: Try it</ThemedText>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
||||
to see changes. Press{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
@@ -38,26 +37,22 @@ export default function HomeScreen() {
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href='/modal'>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type='subtitle'>Step 2: Explore</ThemedText>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title='Action'
|
||||
icon='cube'
|
||||
onPress={() => alert('Action pressed')}
|
||||
/>
|
||||
<Link.MenuAction
|
||||
title='Share'
|
||||
icon='square.and.arrow.up'
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title='More' icon='ellipsis'>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title='Delete'
|
||||
icon='trash'
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
@@ -70,16 +65,13 @@ export default function HomeScreen() {
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type='subtitle'>Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
npm run reset-project
|
||||
</ThemedText>{' '}
|
||||
to get a fresh <ThemedText type='defaultSemiBold'>app</ThemedText>{' '}
|
||||
directory. This will move the current{' '}
|
||||
<ThemedText type='defaultSemiBold'>app</ThemedText> to{' '}
|
||||
<ThemedText type='defaultSemiBold'>app-example</ThemedText>.
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
} from '@react-navigation/native';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://ff2e19b7c72ee50463c6c66b5bef7ce0@sentry.gbrown.org/8',
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 1.0,
|
||||
profilesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
const RootLayout = () => {
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ConvexProvider client={convex}>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name='modal'
|
||||
options={{ presentation: 'modal', title: 'Modal' }}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style='auto' />
|
||||
</ThemeProvider>
|
||||
</ConvexProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
export default Sentry.wrap(RootLayout);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { ThemedView } from '@/components/themed-view';
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>This is a modal</ThemedText>
|
||||
<Link href='/' dismissTo style={styles.link}>
|
||||
<ThemedText type='link'>Go to home screen</ThemedText>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import {
|
||||
openBrowserAsync,
|
||||
WebBrowserPresentationStyle,
|
||||
} from 'expo-web-browser';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
|
||||
href: Href & string;
|
||||
};
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target='_blank'
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
|
||||
@@ -12,8 +12,7 @@ export function HelloWave() {
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
|
||||
@@ -34,15 +34,11 @@ export default function ParallaxScrollView({
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
),
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -52,15 +48,13 @@ export default function ParallaxScrollView({
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
|
||||
@@ -7,16 +7,8 @@ export type ThemedViewProps = ViewProps & {
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
...otherProps
|
||||
}: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor(
|
||||
{ light: lightColor, dark: darkColor },
|
||||
'background',
|
||||
);
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({
|
||||
children,
|
||||
title,
|
||||
}: PropsWithChildren & { title: string }) {
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
@@ -19,17 +16,16 @@ export function Collapsible({
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name='chevron.right'
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight='medium'
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type='defaultSemiBold'>{title}</ThemedText>
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function IconSymbol({
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode='scaleAspectFit'
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
|
||||
@@ -5,10 +5,7 @@ import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<
|
||||
SymbolViewProps['name'],
|
||||
ComponentProps<typeof MaterialIcons>['name']
|
||||
>;
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
@@ -40,12 +37,5 @@ export function IconSymbol({
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<MaterialIcons
|
||||
color={color}
|
||||
size={size}
|
||||
name={MAPPING[name]}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ export const Fonts = Platform.select({
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded:
|
||||
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"assets/*": ["./assets/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"~/*": ["../../packages/backend/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
8
apps/next/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
docker-compose*.yml
|
||||
host/
|
||||
3
apps/next/.gitignore
vendored
@@ -45,6 +45,3 @@ next-env.d.ts
|
||||
|
||||
# Ignored for the template, you probably want to remove it:
|
||||
package-lock.json
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
### Server Variables ###
|
||||
# Next
|
||||
NODE_ENV=
|
||||
SKIP_ENV_VALIDATION=
|
||||
SITE_URL=
|
||||
# Convex
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
SETUP_SCRIPT_RAN=
|
||||
# Sentry
|
||||
SENTRY_AUTH_TOKEN=
|
||||
CI=
|
||||
|
||||
### Client Variables ###
|
||||
# Next
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
# Convex
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
# Sentry
|
||||
# Next # Default Values:
|
||||
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
|
||||
# Sentry # Default Values
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_URL=
|
||||
NEXT_PUBLIC_SENTRY_ORG=
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from './src/env.js';
|
||||
import './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import { withPlausibleProxy } from 'next-plausible';
|
||||
|
||||
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
|
||||
const sentryConfig = {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
org: env.NEXT_PUBLIC_SENTRY_ORG,
|
||||
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
org: 'gib',
|
||||
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !env.CI,
|
||||
silent: !process.env.CI,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev:tunnel": "next dev --turbo",
|
||||
"dev:slow": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@@ -14,9 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "^0.0.81",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -24,41 +22,39 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@sentry/nextjs": "^10.22.0",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.28.0",
|
||||
"convex": "^1.27.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.6",
|
||||
"next": "^15.5.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@types/node": "^20.19.23",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^20.19.13",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"dotenv": "^16.6.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.3.8"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 845 KiB |
|
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 './src/env.js';
|
||||
|
||||
Sentry.init({
|
||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true,
|
||||
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} />
|
||||
<Separator />
|
||||
<UserInfoForm preloadedUser={preloadedUser} />
|
||||
<ResetPasswordForm preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<ResetPasswordForm />
|
||||
<Separator />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
|
||||
@@ -12,17 +12,11 @@ import {
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -30,10 +24,6 @@ import {
|
||||
TabsTrigger,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
GibsAuthSignInButton,
|
||||
MicrosoftSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
|
||||
|
||||
const signInFormSchema = z.object({
|
||||
@@ -50,7 +40,7 @@ const signUpFormSchema = z
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z
|
||||
@@ -85,17 +75,9 @@ const signUpFormSchema = z
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const verifyEmailFormSchema = z.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
});
|
||||
|
||||
const SignIn = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||
'signIn',
|
||||
);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -108,17 +90,12 @@ const SignIn = () => {
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||
resolver: zodResolver(verifyEmailFormSchema),
|
||||
defaultValues: { code },
|
||||
});
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
@@ -126,12 +103,13 @@ const SignIn = () => {
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
await signIn('password', formData);
|
||||
signInForm.reset();
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
toast.error('Error signing in.');
|
||||
} finally {
|
||||
signInForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -146,107 +124,17 @@ const SignIn = () => {
|
||||
try {
|
||||
if (values.confirmPassword !== values.password)
|
||||
throw new ConvexError('Passwords do not match.');
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('email-verification');
|
||||
});
|
||||
await signIn('password', formData);
|
||||
signUpForm.reset();
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
toast.error('Error signing up.');
|
||||
} finally {
|
||||
signUpForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (
|
||||
values: z.infer<typeof verifyEmailFormSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('flow', flow);
|
||||
formData.append('email', email);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
} catch (error) {
|
||||
console.error('Error verifying email:', error);
|
||||
toast.error('Error verifying email.');
|
||||
} finally {
|
||||
verifyEmailForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (flow === 'email-verification') {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<CardContent>
|
||||
<div className='text-center mb-6'>
|
||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||
</div>
|
||||
<Form {...verifyEmailForm}>
|
||||
<form
|
||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSeparator />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your email.
|
||||
</FormDescription>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Verify Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='text-center mt-4'>
|
||||
<button
|
||||
onClick={() => setFlow('signUp')}
|
||||
className='text-sm text-muted-foreground hover:underline'
|
||||
>
|
||||
Back to Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
@@ -323,28 +211,12 @@ const SignIn = () => {
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex justify-center'>
|
||||
<div
|
||||
className='flex flex-row items-center
|
||||
my-2.5 mx-auto justify-center w-1/4'
|
||||
>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<MicrosoftSignInButton />
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -437,25 +309,12 @@ const SignIn = () => {
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex my-auto justify-center w-2/3'>
|
||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<MicrosoftSignInButton type='signUp' />
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -38,36 +38,38 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
return (
|
||||
<PlausibleProvider
|
||||
domain='techtracker.gbrown.org'
|
||||
customDomain='https://plausible.gbrown.org'
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
<ConvexClientProvider>
|
||||
<PlausibleProvider
|
||||
domain='techtracker.gbrown.org'
|
||||
customDomain='https://plausible.gbrown.org'
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<TVModeProvider>
|
||||
<Header />
|
||||
<main className='min-h-[90vh] flex flex-col items-center'>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
</main>
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<TVModeProvider>
|
||||
<Header />
|
||||
<main className='min-h-[90vh] flex flex-col items-center'>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
</main>
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
</ConvexClientProvider>
|
||||
);
|
||||
};
|
||||
export default GlobalError;
|
||||
|
||||
@@ -4,8 +4,6 @@ import '@/styles/globals.css';
|
||||
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||
import {
|
||||
ConvexClientProvider,
|
||||
LunchReminder,
|
||||
NotificationsPermission,
|
||||
ThemeProvider,
|
||||
TVModeProvider,
|
||||
} from '@/components/providers';
|
||||
@@ -24,11 +22,11 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
export const metadata: Metadata = generateMetadata();
|
||||
|
||||
const RootLayout = async ({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
}>) {
|
||||
return (
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<PlausibleProvider
|
||||
@@ -50,8 +48,6 @@ const RootLayout = async ({
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
<NotificationsPermission />
|
||||
<LunchReminder />
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
@@ -60,5 +56,4 @@ const RootLayout = async ({
|
||||
</PlausibleProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
);
|
||||
};
|
||||
export default RootLayout;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { type ChangeEvent, useRef, useState } from 'react';
|
||||
import {
|
||||
type Preloaded,
|
||||
@@ -9,14 +10,13 @@ import {
|
||||
} from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
Button,
|
||||
CardContent,
|
||||
ImageCrop,
|
||||
ImageCropApply,
|
||||
ImageCropContent,
|
||||
ImageCropReset,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
@@ -48,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
const updateUserImage = useMutation(api.auth.updateUserImage);
|
||||
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
@@ -97,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
storageId: Id<'_storage'>;
|
||||
};
|
||||
|
||||
await updateUser({ image: uploadResponse.storageId });
|
||||
await updateUserImage({ storageId: uploadResponse.storageId });
|
||||
|
||||
toast.success('Profile picture updated.');
|
||||
handleReset();
|
||||
@@ -121,7 +121,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className='h-42 w-42 text-6xl font-semibold'
|
||||
className='h-32 w-32'
|
||||
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
||||
userIconProps={{ size: 100 }}
|
||||
/>
|
||||
<div
|
||||
@@ -172,6 +173,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
<ImageCropContent className='max-w-sm' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<ImageCropApply />
|
||||
<ImageCropReset />
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
@@ -188,14 +190,19 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
{/* Cropped preview + actions */}
|
||||
{croppedImage && (
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<Avatar className='h-42 w-42'>
|
||||
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
||||
</Avatar>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Image
|
||||
alt='Cropped preview'
|
||||
className='overflow-hidden rounded-full'
|
||||
height={128}
|
||||
src={croppedImage}
|
||||
unoptimized
|
||||
width={128}
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isUploading}
|
||||
className='px-4'
|
||||
className='px-6'
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
@@ -210,10 +217,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
type='button'
|
||||
className='dark:bg-red-500/30 bg-red-400/80
|
||||
hover:dark:text-red-300/60 hover:text-red-800/80
|
||||
hover:dark:bg-accent'
|
||||
variant='secondary'
|
||||
variant='ghost'
|
||||
>
|
||||
<XIcon className='size-4' />
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { useAction } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { z } from 'zod';
|
||||
import { type Preloaded, usePreloadedQuery } from 'convex/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
@@ -64,12 +62,7 @@ const formSchema = z
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type ResetFormProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
export const ResetPasswordForm = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changePassword = useAction(api.auth.updateUserPassword);
|
||||
@@ -101,12 +94,10 @@ export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return user?.provider !== 'password' ? (
|
||||
<div />
|
||||
) : (
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<CardHeader>
|
||||
<CardHeader className='pb-5'>
|
||||
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { z } from 'zod';
|
||||
@@ -7,9 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
@@ -19,7 +16,6 @@ import {
|
||||
FormMessage,
|
||||
Input,
|
||||
SubmitButton,
|
||||
Switch,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -36,11 +32,6 @@ const formSchema = z.object({
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
lunchTime: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Must be a valid 24-hour time. Example: 13:00' }),
|
||||
automaticLunch: z.boolean(),
|
||||
});
|
||||
|
||||
type UserInfoFormProps = {
|
||||
@@ -51,47 +42,28 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
||||
() => ({
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
lunchTime: user?.lunchTime ?? '12:00',
|
||||
automaticLunch: user?.automaticLunch ?? false,
|
||||
}),
|
||||
[user?.name, user?.email, user?.lunchTime, user?.automaticLunch],
|
||||
);
|
||||
const updateUserName = useMutation(api.auth.updateUserName);
|
||||
const updateUserEmail = useMutation(api.auth.updateUserEmail);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: initialValues,
|
||||
defaultValues: {
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const ops: Promise<unknown>[] = [];
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const lunchTime = values.lunchTime.trim();
|
||||
const automaticLunch = values.automaticLunch;
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
lunchTime: string;
|
||||
automaticLunch: boolean;
|
||||
}> = {};
|
||||
if (name !== (user?.name ?? '') && name !== undefined)
|
||||
patch.name = name;
|
||||
if (email !== (user?.email ?? '') && email !== undefined)
|
||||
patch.email = email;
|
||||
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
|
||||
patch.lunchTime = lunchTime;
|
||||
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
|
||||
patch.automaticLunch = automaticLunch;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
|
||||
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
|
||||
if (ops.length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(patch);
|
||||
form.reset(patch);
|
||||
await Promise.all(ops);
|
||||
form.reset({ name, email });
|
||||
toast.success('Profile updated successfully.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -102,107 +74,48 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl'>Account Information</CardTitle>
|
||||
<CardDescription>Update your account information here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={user?.provider !== 'password'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex flex-row justify-center space-x-10'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='lunchTime'
|
||||
render={({ field }) => (
|
||||
<FormItem className='sm:w-2/5'>
|
||||
<div className='flex flex-row space-x-2 my-auto'>
|
||||
<FormLabel>Lunch Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='time' className='w-28' {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormDescription>Your regular lunch time.</FormDescription>
|
||||
<FormDescription className='dark:text-red-300/60 text-red-800/80'>
|
||||
{!user?.lunchTime && 'Not currently set.'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='automaticLunch'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-2/5 mt-2'>
|
||||
<div className='flex flex-row space-x-2 my-auto'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className='border-solid border-primary'
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Automatic Lunch</FormLabel>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Automatically take your lunch at the time you specify.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-center mt-5'>
|
||||
<SubmitButton
|
||||
className='lg:w-1/3 w-2/3 text-[1.0rem]'
|
||||
disabled={loading}
|
||||
pendingText='Saving...'
|
||||
>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton disabled={loading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
Input,
|
||||
Label,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -51,7 +49,6 @@ export const StatusList = ({
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [persistStatus, setPersistStatus] = useState(false);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
|
||||
const [previousStatuses, setPreviousStatuses] = useState(statuses);
|
||||
@@ -101,24 +98,11 @@ export const StatusList = ({
|
||||
throw new Error('Status must be between 3 & 80 characters');
|
||||
}
|
||||
if (selectedUserIds.length === 0 && user?.id) {
|
||||
await bulkCreate({
|
||||
message,
|
||||
userIds: [user.id],
|
||||
persistentStatus: persistStatus
|
||||
});
|
||||
await bulkCreate({ message, userIds: [user.id] });
|
||||
} else {
|
||||
await bulkCreate({
|
||||
message,
|
||||
userIds: selectedUserIds,
|
||||
persistentStatus: persistStatus
|
||||
});
|
||||
await bulkCreate({ message, userIds: selectedUserIds });
|
||||
}
|
||||
toast.success('Status updated.');
|
||||
toast.success('Status updated.', {
|
||||
duration: 2000,
|
||||
closeButton: true,
|
||||
dismissible: true,
|
||||
});
|
||||
setSelectedUserIds([]);
|
||||
setSelectAll(false);
|
||||
setStatusInput('');
|
||||
@@ -140,9 +124,9 @@ export const StatusList = ({
|
||||
|
||||
const containerCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'mx-auto',
|
||||
className: 'max-w-4xl mx-auto',
|
||||
on: 'px-6',
|
||||
off: 'max-w-4xl px-4 sm:px-6',
|
||||
off: 'px-4 sm:px-6',
|
||||
});
|
||||
|
||||
const tabsCn = ccn({
|
||||
@@ -165,13 +149,13 @@ export const StatusList = ({
|
||||
<TabsList className={tabsCn}>
|
||||
<TabsTrigger value='status' className='py-3 sm:py-8'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<Activity className='text-primary sm:scale-150' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>Status List</h1>
|
||||
<Activity className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>Team Status</h1>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='history' className='py-3 sm:py-8'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<History className='text-primary sm:scale-150' />
|
||||
<History className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>
|
||||
Status History
|
||||
</h1>
|
||||
@@ -201,26 +185,22 @@ export const StatusList = ({
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className={headerCn}>
|
||||
<div className='flex w-full justify-between px-4'>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
||||
<span>{statuses.length} members</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
||||
<span>{statuses.length} members</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-xs'>
|
||||
<Link href='/table' className='font-medium hover:underline'>
|
||||
Miss the old table?
|
||||
</Link>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-xs'>
|
||||
<Link href='/table' className='font-medium hover:underline'>
|
||||
Miss the old table?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
|
||||
{previousStatuses.map((statusData) => {
|
||||
{statuses.map((statusData) => {
|
||||
const { user: u, status: s } = statusData;
|
||||
const isSelected = selectedUserIds.includes(u.id);
|
||||
const isAnimating = animatingIds.has(u.id);
|
||||
@@ -232,7 +212,6 @@ export const StatusList = ({
|
||||
className={`
|
||||
relative rounded-xl border transition-all
|
||||
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
|
||||
${s?.persistentStatus ? 'bg-black/10' : ''}
|
||||
${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
@@ -253,46 +232,43 @@ export const StatusList = ({
|
||||
|
||||
<div className='flex items-start gap-3 sm:gap-4'>
|
||||
{/* Avatar */}
|
||||
<div className='shrink-0'>
|
||||
<div className='flex-shrink-0'>
|
||||
<BasedAvatar
|
||||
src={u.imageUrl}
|
||||
fullName={u.name ?? 'User'}
|
||||
className={`
|
||||
transition-all duration-300
|
||||
${tvMode ? 'w-36 h-36 text-4xl' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
||||
${tvMode ? 'w-18 h-18' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
||||
${isAnimating ? 'ring-primary/30 ring-4' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
|
||||
<h3
|
||||
className={`font-semibold
|
||||
${tvMode ? 'text-5xl' : 'text-base sm:text-xl'}
|
||||
className={`
|
||||
font-semibold truncate
|
||||
${tvMode ? 'text-3xl' : 'text-base sm:text-xl'}
|
||||
`}
|
||||
title={u.name ?? u.email ?? 'User'}
|
||||
>
|
||||
{u.name ?? u.email ?? 'User'}
|
||||
</h3>
|
||||
|
||||
{isUpdatedByOther && s?.updatedBy && (
|
||||
<div
|
||||
className='hidden sm:flex items-center gap-2
|
||||
text-muted-foreground min-w-0'
|
||||
>
|
||||
<span
|
||||
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
|
||||
>
|
||||
via
|
||||
</span>
|
||||
<span className='text-sm'>via</span>
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name ?? 'User'}
|
||||
className={`${tvMode ? 'w-14 h-14 text-xl' : 'w-6 h-6'}`}
|
||||
className='w-4 h-4'
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-4xl' : 'truncate'}`}
|
||||
>
|
||||
<span className='text-sm truncate'>
|
||||
{s.updatedBy.name ??
|
||||
s.updatedBy.email ??
|
||||
'another user'}
|
||||
@@ -300,67 +276,51 @@ export const StatusList = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`
|
||||
mb-2 sm:mb-3
|
||||
${tvMode ? 'text-6xl' : 'text-[0.95rem] sm:text-lg'}
|
||||
${s ? 'text-foreground' : 'text-muted-foreground italic'}
|
||||
mb-2 sm:mb-3 leading-relaxed break-words
|
||||
${tvMode ? 'text-2xl' : 'text-[0.95rem] sm:text-lg'}
|
||||
${
|
||||
s
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground italic'
|
||||
}
|
||||
line-clamp-2
|
||||
`}
|
||||
title={s?.message ?? undefined}
|
||||
>
|
||||
{s?.message ?? 'No status yet.'}
|
||||
</div>
|
||||
{/* Meta - only show here when NOT in TV mode */}
|
||||
{!tvMode && (
|
||||
<div className='flex items-center text-muted-foreground gap-3 sm:gap-4'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Calendar className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||
</span>
|
||||
</div>
|
||||
{s && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Activity className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{getStatusAge(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/Time Column - only show when in TV mode */}
|
||||
{tvMode && (
|
||||
<div className='flex flex-col items-end gap-2 text-muted-foreground min-w-0'>
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
className='flex items-center gap-3 sm:gap-4
|
||||
text-muted-foreground'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Clock className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Calendar className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
<div className='hidden xs:flex items-center gap-1.5'>
|
||||
<Calendar className='w-4 h-4' />
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||
</span>
|
||||
</div>
|
||||
{s && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Activity className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
<Activity className='w-4 h-4' />
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{getStatusAge(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!tvMode && (
|
||||
<div className='flex flex-col items-end gap-2'>
|
||||
@@ -381,6 +341,7 @@ export const StatusList = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile "via user" line */}
|
||||
{isUpdatedByOther && s?.updatedBy && (
|
||||
<div
|
||||
@@ -414,29 +375,17 @@ export const StatusList = ({
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex gap-3 w-full justify-between'>
|
||||
<div className='flex gap-3 items-center'>
|
||||
<Zap className='w-6 h-6 text-primary' />
|
||||
<h3 className='text-xl font-semibold'>Update Status</h3>
|
||||
{selectedUserIds.length > 0 && (
|
||||
<span
|
||||
className='px-2 py-1 bg-primary/10 text-primary
|
||||
text-sm rounded-full'
|
||||
>
|
||||
{selectedUserIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex space-x-2 items-center'>
|
||||
<Checkbox
|
||||
checked={persistStatus}
|
||||
className='border border-primary'
|
||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
||||
/>
|
||||
<Label>
|
||||
Persist Status
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Zap className='w-5 h-5 text-primary' />
|
||||
<h3 className='text-lg font-semibold'>Update Status</h3>
|
||||
{selectedUserIds.length > 0 && (
|
||||
<span
|
||||
className='px-2 py-1 bg-primary/10 text-primary
|
||||
text-sm rounded-full'
|
||||
>
|
||||
{selectedUserIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-3'>
|
||||
<Input
|
||||
@@ -494,7 +443,7 @@ export const StatusList = ({
|
||||
<div
|
||||
className='md:hidden fixed bottom-0 left-0 right-0 z-50
|
||||
border-t bg-background/95 backdrop-blur
|
||||
supports-backdrop-filter:bg-background/60 p-3
|
||||
supports-[backdrop-filter]:bg-background/60 p-3
|
||||
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
@@ -507,16 +456,6 @@ export const StatusList = ({
|
||||
Update your status
|
||||
</span>
|
||||
)}
|
||||
<div className='flex flex-row space-x-2'>
|
||||
<Checkbox
|
||||
className='border border-primary'
|
||||
checked={persistStatus}
|
||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
||||
/>
|
||||
<Label className='text-xs'>
|
||||
Persist Status
|
||||
</Label>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' onClick={handleSelectAll}>
|
||||
{selectAll ? 'Clear' : 'Select all'}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTVMode } from '@/components/providers';
|
||||
import {
|
||||
BasedAvatar,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
Input,
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
||||
import { Clock, Calendar } from 'lucide-react';
|
||||
import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||
import { StatusHistory } from '@/components/layout/status';
|
||||
|
||||
type StatusTableProps = {
|
||||
@@ -118,7 +120,7 @@ export const StatusTable = ({
|
||||
</div>
|
||||
<table className='w-full text-center rounded-md'>
|
||||
<thead>
|
||||
<tr className='dark:bg-muted bg-accent/30'>
|
||||
<tr className='bg-muted'>
|
||||
{!tvMode && (
|
||||
<th className={tCheckboxCn}>
|
||||
<input
|
||||
@@ -149,11 +151,7 @@ export const StatusTable = ({
|
||||
<tr
|
||||
key={u.id}
|
||||
className={`
|
||||
${
|
||||
i % 2 === 0
|
||||
? 'dark:bg-muted/20 bg-muted'
|
||||
: 'dark:bg-muted/80 bg-accent/50'
|
||||
}
|
||||
${i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
@@ -173,7 +171,7 @@ export const StatusTable = ({
|
||||
<BasedAvatar
|
||||
src={u.imageUrl}
|
||||
fullName={u.name}
|
||||
className={tvMode ? 'w-16 h-16 text-2xl' : 'w-12 h-12'}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
|
||||
@@ -182,7 +180,7 @@ export const StatusTable = ({
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name}
|
||||
className='w-5 h-5 text-xs'
|
||||
className='w-5 h-5'
|
||||
/>
|
||||
<span className={tvMode ? 'text-xl' : 'text-base'}>
|
||||
Updated by {s.updatedBy.name}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
export { LunchReminder } from './lunch-reminder';
|
||||
export { NotificationsPermission } from './notification-permission';
|
||||
export {
|
||||
ThemeProvider,
|
||||
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,
|
||||
CardContent,
|
||||
} from './card';
|
||||
export { Checkbox } from './checkbox';
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
@@ -55,12 +54,6 @@ export {
|
||||
ImageCropReset,
|
||||
} from './shadcn-io/image-crop';
|
||||
export { Input } from './input';
|
||||
export {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from './input-otp';
|
||||
export { Label } from './label';
|
||||
export {
|
||||
Pagination,
|
||||
@@ -76,7 +69,6 @@ export { ScrollArea, ScrollBar } from './scroll-area';
|
||||
export { Separator } from './separator';
|
||||
export { StatusMessage } from './status-message';
|
||||
export { SubmitButton } from './submit-button';
|
||||
export { Switch } from './switch';
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
|
||||
@@ -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'])
|
||||
.default('development'),
|
||||
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
||||
SITE_URL: z.url().default('http://localhost:3000'),
|
||||
SENTRY_AUTH_TOKEN: z.string(),
|
||||
CI: z.boolean().default(true),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_CONVEX_URL: z.url(),
|
||||
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
|
||||
NEXT_PUBLIC_CONVEX_URL: z
|
||||
.url()
|
||||
.default('https://api.dev.convex.gbrown.org'),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z
|
||||
.url()
|
||||
.default('https://96df775337cce23d925616dd5aea8857@sentry.gbrown.org/2'),
|
||||
NEXT_PUBLIC_SENTRY_URL: z.url().default('https://sentry.gbrown.org'),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.url(),
|
||||
NEXT_PUBLIC_SENTRY_URL: z.url(),
|
||||
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string().default('techtracker-next'),
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
||||
SITE_URL: process.env.SITE_URL,
|
||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||
CI: process.env.CI,
|
||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||
|
||||
@@ -3,21 +3,14 @@ import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
|
||||
integrations: [
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false,
|
||||
}),
|
||||
],
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||
sendDefaultPii: true,
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true,
|
||||
tracesSampleRate: 1.0,
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||
replaysSessionSampleRate: 0.5,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
debug: false,
|
||||
});
|
||||
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import type { Instrumentation } from 'next';
|
||||
|
||||
export const register = async () => await import('../sentry.server.config');
|
||||
export const register = async () => {
|
||||
await import('../sentry.server.config');
|
||||
};
|
||||
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
|
||||
@@ -51,8 +51,7 @@ export const formatDate = (timestamp: Timestamp, locale = 'en-US'): string => {
|
||||
const date = toDate(timestamp);
|
||||
if (!date) return '--/--';
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,19 +8,16 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
|
||||
const isSignInPage = createRouteMatcher(['/signin']);
|
||||
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
||||
|
||||
export default convexAuthNextjsMiddleware(
|
||||
async (request, { convexAuth }) => {
|
||||
const banResponse = banSuspiciousIPs(request);
|
||||
if (banResponse) return banResponse;
|
||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/');
|
||||
}
|
||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/signin');
|
||||
}
|
||||
},
|
||||
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
|
||||
);
|
||||
export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
|
||||
const banResponse = banSuspiciousIPs(request);
|
||||
if (banResponse) return banResponse;
|
||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/');
|
||||
}
|
||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/signin');
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// The following matcher runs middleware on all routes
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- Bun on Alpine for build ---
|
||||
FROM oven/bun:alpine AS base
|
||||
|
||||
# --- deps: install node_modules with Bun ---
|
||||
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
|
||||
|
||||
# Copy package + whichever Bun lock file you have (optional)
|
||||
COPY package.json bun.lockb* bun.lock* ./
|
||||
COPY tsconfig.base.json ./
|
||||
COPY apps/next/package.json ./apps/next/
|
||||
|
||||
# If bun.lockb exists, enforce frozen; otherwise install and generate it
|
||||
RUN if [ -f bun.lockb ]; then \
|
||||
bun install --frozen-lockfile; \
|
||||
else \
|
||||
bun install; \
|
||||
fi
|
||||
|
||||
# --- builder: build Next.js with Bun ---
|
||||
FROM base AS builder
|
||||
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 ./
|
||||
COPY --from=deps /app/tsconfig.base.json ./tsconfig.base.json
|
||||
COPY apps/next ./apps/next
|
||||
COPY packages ./packages
|
||||
WORKDIR /app/apps/next
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN bun run build
|
||||
|
||||
#FROM base AS runner
|
||||
# --- runner: Node on Alpine to run server.js ---
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# non-root user
|
||||
RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001
|
||||
#RUN adduser --system --uid 1001 nextjs
|
||||
#RUN chown nextjs:bun .next
|
||||
COPY --from=builder /app/apps/next/public ./public
|
||||
RUN mkdir .next && chown -R nextjs:nodejs .next
|
||||
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next
|
||||
|
||||
# Copy from the correct paths (builder has apps/next at root)
|
||||
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
|
||||
# Next standalone output
|
||||
COPY --from=builder /app/apps/next/.next/standalone ./
|
||||
COPY --from=builder /app/apps/next/.next/static ./.next/static
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -7,18 +7,17 @@ services:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
image: ${NEXT_CONTAINER_NAME}:alpine
|
||||
container_name: ${NEXT_CONTAINER_NAME}
|
||||
image: ${CONTAINER_NAME}:alpine
|
||||
container_name: ${CONTAINER_NAME}
|
||||
env_file: [.env]
|
||||
hostname: ${NEXT_CONTAINER_NAME}
|
||||
domainname: ${NEXT_DOMAIN_NAME}
|
||||
hostname: ${CONTAINER_NAME}
|
||||
domainname: ${DOMAIN_NAME}
|
||||
networks: ['${NETWORK:-nginx-bridge}']
|
||||
#ports: ['${NEXT_PORT}:3000']
|
||||
#ports: ['${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}
|
||||
|
||||
1
docker/data/credentials/instance_name
Normal file
@@ -0,0 +1 @@
|
||||
Convex.gib
|
||||
1
docker/data/credentials/instance_secret
Normal file
@@ -0,0 +1 @@
|
||||
d35a9c02aeef0070fac3069d51ac4926744b251d0e6d97dc239d78bce67959b6
|
||||
BIN
docker/data/db.sqlite3
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 992 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.4 MiB |