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