Compare commits
58 Commits
87b3f61717
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c523b8246 | |||
| d2517901a8 | |||
| 4306d69558 | |||
| 1040250928 | |||
| 7eb3a1dff0 | |||
| 40489be8e9 | |||
| 467d452bb4 | |||
| db7bf75815 | |||
| bd4e757318 | |||
| ab278c2ae8 | |||
| 914c45dca4 | |||
| 70924f84a9 | |||
| 4fe32474df | |||
| 288f464f6a | |||
| 14017faa07 | |||
| 1ee287b26c | |||
| e286d02c26 | |||
| d4d690eb15 | |||
| a8bbfebd00 | |||
| f93b39d7a9 | |||
| 45d2461781 | |||
| fd2999e9bb | |||
| e7cdf7f754 | |||
| 6249d51311 | |||
| 3092ada03a | |||
| 8677bee1a9 | |||
| 87c128f7c5 | |||
| 3d85e0c2e9 | |||
| 3bff31c07a | |||
| d4842fdacd | |||
| 92854382db | |||
| 84bfc21877 | |||
| b737fa22c3 | |||
| 7e7e92b89a | |||
| 3a0263b96b | |||
| 926f31b3aa | |||
| 83e1a41ab8 | |||
| d918a3d01a | |||
| 641abf7801 | |||
| ddce36c366 | |||
| c218d2edc2 | |||
| 1e4df157e9 | |||
| 4d81e1f8e6 | |||
| b1eae564be | |||
| 4cafc11422 | |||
| 0ce699d44c | |||
| 136047ca25 | |||
| 52be5c93f4 | |||
| bf12031773 | |||
| 603a23983c | |||
| 37c3767c71 | |||
| 3eff470a80 | |||
| 7d110bee8e | |||
| ea5712bdfa | |||
| 7b78c4a658 | |||
| fa447c42cb | |||
| 5485f3d28f | |||
| c227d42e5e |
@@ -1,8 +0,0 @@
|
||||
.git
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
docker-compose*.yml
|
||||
host/
|
||||
46
.gitignore
vendored
@@ -1,47 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Hosting
|
||||
/host/convex/docker/data
|
||||
/docker/data
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
.next/
|
||||
.swc/
|
||||
out/
|
||||
build
|
||||
|
||||
# production
|
||||
/build
|
||||
# expo
|
||||
.expo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
dist
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Ignored for the template, you probably want to remove it:
|
||||
package-lock.json
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
|
||||
|
||||
51
README.md
@@ -1,8 +1,8 @@
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<img
|
||||
src="https://git.gbrown.org/gib/techtracker/raw/branch/main/public/favicon.png"
|
||||
alt="Next Template"
|
||||
src="https://git.gbrown.org/gib/techtracker/raw/branch/main/apps/next/public/favicon.png"
|
||||
alt="Tech Tracker"
|
||||
width="100"
|
||||
>
|
||||
<br>
|
||||
@@ -31,25 +31,30 @@ I would recommend using [bun](https://bun.sh/) to install dependencies.
|
||||
bun i
|
||||
```
|
||||
|
||||
You will also need docker installed on whatever host you plan to run the 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!
|
||||
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!
|
||||
|
||||
### Add your environment variables
|
||||
|
||||
Copy the example environment variable files and paste them in the same directory named `.env`.
|
||||
|
||||
```bash
|
||||
cp ./env.example ./.env
|
||||
```
|
||||
Environment variables for Next Application
|
||||
|
||||
```bash
|
||||
cp ./host/convex/docker/env.example ./host/convex/docker/.env
|
||||
cp ./apps/next/env.example ./apps/next/.env
|
||||
```
|
||||
|
||||
### Start self hosted convex
|
||||
Environment variables for Self Hosting Convex & Website with Docker
|
||||
|
||||
```bash
|
||||
cp ./docker/env.example ./docker/.env
|
||||
```
|
||||
|
||||
### Start self hosted convex & Next Web Application
|
||||
|
||||
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
|
||||
|
||||
```bash
|
||||
cd ./host/convex/docker
|
||||
cd ./docker
|
||||
sudo docker compose up -d
|
||||
sudo docker compose exec convex-backend ./generate_admin_key.sh
|
||||
```
|
||||
@@ -58,36 +63,10 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
|
||||
|
||||
Run
|
||||
|
||||
```bash
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
to start your development environment with turbopack
|
||||
|
||||
You can also run
|
||||
|
||||
```bash
|
||||
bun dev:slow
|
||||
```
|
||||
|
||||
to start your development environment with webpack
|
||||
|
||||
### Start your Production Environment.
|
||||
|
||||
There are Dockerfiles & docker compose files that can be found in the `./scripts/docker` folder for the Next.js website. There is also a script called `reload_container` located in the `./scripts/` folder which was created to quickly update the container, but this will give you a better idea of what you need to do. First, build the image with
|
||||
|
||||
```bash
|
||||
sudo docker compose -f ./host/next/docker/compose.yml build
|
||||
```
|
||||
|
||||
then you can run the container with
|
||||
|
||||
```bash
|
||||
sudo docker compose -f ./host/next/docker/compose up -d
|
||||
```
|
||||
|
||||
Now, you may end up with some build errors. The `reload_containers` script swaps out the next config before it runs the docker build to skip any build errors, so you may want to do this as well, though you are welcome to fix the build errors as well, of course!
|
||||
|
||||
### Fin
|
||||
|
||||
I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here!
|
||||
|
||||
43
apps/expo/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
90
apps/expo/app.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Tech Tracker",
|
||||
"owner": "gib",
|
||||
"slug": "techtracker-expo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "org.gbrown.techtrackerexpo",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#2e2f3d"
|
||||
},
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"usesAppleSignIn": true,
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.gbrown.techtracker",
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSLocationWhenInUseUsageDescription": "This app uses your location in order to allow you to share your location in chat.",
|
||||
"NSCameraUsageDescription": "This app uses your camera to take photos & send them in the chat."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#2e2f3d"
|
||||
},
|
||||
"permissions": [
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.INTERNET"
|
||||
],
|
||||
"package": "com.gbrown.techtracker"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-apple-authentication",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"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": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/expo/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
apps/expo/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
apps/expo/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/expo/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/expo/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/expo/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/expo/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/expo/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/expo/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/expo/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/expo/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/expo/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/expo/assets/images/splash.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
apps/expo/assets/images/tech_tracker_logo.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
10
apps/expo/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
]);
|
||||
5
apps/expo/metro.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
|
||||
// const config = getDefaultConfig(__dirname);
|
||||
const config = getSentryExpoConfig(__dirname);
|
||||
module.exports = config;
|
||||
49
apps/expo/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "techtracker-expo",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"dev:tunnel": "expo start --tunnel",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.6.0",
|
||||
"@react-navigation/elements": "^2.7.1",
|
||||
"@react-navigation/native": "^7.1.19",
|
||||
"@sentry/react-native": "^7.4.0",
|
||||
"expo": "~54.0.20",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.3",
|
||||
"react-native-safe-area-context": "~5.6.1",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.2",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.17",
|
||||
"eslint-config-expo": "~10.0.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
40
apps/expo/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name='index'
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name='house.fill' color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name='explore'
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name='paperplane.fill' color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
125
apps/expo/src/app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color='#808080'
|
||||
name='chevron.left.forwardslash.chevron.right'
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type='title'
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}
|
||||
>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>
|
||||
This app includes example code to help you get started.
|
||||
</ThemedText>
|
||||
<Collapsible title='File-based routing'>
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
||||
and{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href='https://docs.expo.dev/router/introduction'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Android, iOS, and web support'>
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the
|
||||
web version, press <ThemedText type='defaultSemiBold'>w</ThemedText>{' '}
|
||||
in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title='Images'>
|
||||
<ThemedText>
|
||||
For static images, you can use the{' '}
|
||||
<ThemedText type='defaultSemiBold'>@2x</ThemedText> and{' '}
|
||||
<ThemedText type='defaultSemiBold'>@3x</ThemedText> suffixes to
|
||||
provide files for different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href='https://reactnative.dev/docs/images'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Light and dark mode components'>
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type='defaultSemiBold'>useColorScheme()</ThemedText> hook
|
||||
lets you inspect what the user's current color scheme is, and so
|
||||
you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href='https://docs.expo.dev/develop/user-interface/color-themes/'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Animations'>
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
components/HelloWave.tsx
|
||||
</ThemedText>{' '}
|
||||
component uses the powerful{' '}
|
||||
<ThemedText type='defaultSemiBold' style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
components/ParallaxScrollView.tsx
|
||||
</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
106
apps/expo/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type='title'>Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type='subtitle'>Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
||||
to see changes. Press{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12',
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href='/modal'>
|
||||
<Link.Trigger>
|
||||
<ThemedText type='subtitle'>Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction
|
||||
title='Action'
|
||||
icon='cube'
|
||||
onPress={() => alert('Action pressed')}
|
||||
/>
|
||||
<Link.MenuAction
|
||||
title='Share'
|
||||
icon='square.and.arrow.up'
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title='More' icon='ellipsis'>
|
||||
<Link.MenuAction
|
||||
title='Delete'
|
||||
icon='trash'
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type='subtitle'>Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
npm run reset-project
|
||||
</ThemedText>{' '}
|
||||
to get a fresh <ThemedText type='defaultSemiBold'>app</ThemedText>{' '}
|
||||
directory. This will move the current{' '}
|
||||
<ThemedText type='defaultSemiBold'>app</ThemedText> to{' '}
|
||||
<ThemedText type='defaultSemiBold'>app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
44
apps/expo/src/app/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
} from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://ff2e19b7c72ee50463c6c66b5bef7ce0@sentry.gbrown.org/8',
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 1.0,
|
||||
profilesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
const RootLayout = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ConvexProvider client={convex}>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name='modal'
|
||||
options={{ presentation: 'modal', title: 'Modal' }}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style='auto' />
|
||||
</ThemeProvider>
|
||||
</ConvexProvider>
|
||||
);
|
||||
};
|
||||
export default Sentry.wrap(RootLayout);
|
||||
29
apps/expo/src/app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>This is a modal</ThemedText>
|
||||
<Link href='/' dismissTo style={styles.link}>
|
||||
<ThemedText type='link'>Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
30
apps/expo/src/components/external-link.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import {
|
||||
openBrowserAsync,
|
||||
WebBrowserPresentationStyle,
|
||||
} from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
|
||||
href: Href & string;
|
||||
};
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target='_blank'
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
apps/expo/src/components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
apps/expo/src/components/hello-wave.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}
|
||||
>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
85
apps/expo/src/components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
60
apps/expo/src/components/themed-text.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
22
apps/expo/src/components/themed-view.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
...otherProps
|
||||
}: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor(
|
||||
{ light: lightColor, dark: darkColor },
|
||||
'background',
|
||||
);
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
49
apps/expo/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({
|
||||
children,
|
||||
title,
|
||||
}: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<IconSymbol
|
||||
name='chevron.right'
|
||||
size={18}
|
||||
weight='medium'
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type='defaultSemiBold'>{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
32
apps/expo/src/components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode='scaleAspectFit'
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
apps/expo/src/components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<
|
||||
SymbolViewProps['name'],
|
||||
ComponentProps<typeof MaterialIcons>['name']
|
||||
>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<MaterialIcons
|
||||
color={color}
|
||||
size={size}
|
||||
name={MAPPING[name]}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
apps/expo/src/constants/theme.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
tabIconSelected: tintColorLight,
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded:
|
||||
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
1
apps/expo/src/hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
apps/expo/src/hooks/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
21
apps/expo/src/hooks/use-theme-color.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
15
apps/expo/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"assets/*": ["./assets/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"~/*": ["../../packages/backend/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
50
apps/next/.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Hosting
|
||||
/host/convex/docker/data
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Ignored for the template, you probably want to remove it:
|
||||
package-lock.json
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
@@ -1,18 +1,19 @@
|
||||
### Server Variables ###
|
||||
# Convex
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
SETUP_SCRIPT_RAN=
|
||||
# Next
|
||||
NODE_ENV=
|
||||
SKIP_ENV_VALIDATION=
|
||||
SITE_URL=
|
||||
# Sentry
|
||||
SENTRY_AUTH_TOKEN=
|
||||
CI=
|
||||
|
||||
### Client Variables ###
|
||||
# Next # Default Values:
|
||||
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
|
||||
# Sentry # Default Values
|
||||
# Next
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
# Convex
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
# Sentry
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_URL=
|
||||
NEXT_PUBLIC_SENTRY_ORG=
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
||||
|
||||
24
apps/next/eslint.config.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { baseConfig } from '../../eslint.config.base.js';
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['.next'] },
|
||||
...compat.extends('next/core-web-vitals'),
|
||||
baseConfig,
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import './src/env.js';
|
||||
import { env } from './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import { withPlausibleProxy } from 'next-plausible';
|
||||
|
||||
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
|
||||
const sentryConfig = {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
org: 'gib',
|
||||
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: env.NEXT_PUBLIC_SENTRY_ORG,
|
||||
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
silent: !env.CI,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
64
apps/next/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "techtracker-next",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev:tunnel": "next dev --turbo",
|
||||
"dev:slow": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "^0.0.81",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@sentry/nextjs": "^10.22.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.28.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.6",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@types/node": "^20.19.23",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
BIN
apps/next/public/icons/misc/gibs-auth-logo.png
Normal file
|
After Width: | Height: | Size: 845 KiB |
BIN
apps/next/public/icons/misc/gibs-auth.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
9
apps/next/sentry.server.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { env } from './src/env.js';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
Sentry.init({
|
||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true,
|
||||
debug: false,
|
||||
});
|
||||
14
apps/next/src/app/(auth)/forgot-password/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
298
apps/next/src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'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,8 +18,7 @@ const Profile = async () => {
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<UserInfoForm preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<ResetPasswordForm />
|
||||
<ResetPasswordForm preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
@@ -12,11 +12,17 @@ import {
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -24,6 +30,10 @@ import {
|
||||
TabsTrigger,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
GibsAuthSignInButton,
|
||||
MicrosoftSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
|
||||
|
||||
const signInFormSchema = z.object({
|
||||
@@ -40,7 +50,7 @@ const signUpFormSchema = z
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.string().email({
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z
|
||||
@@ -75,9 +85,17 @@ const signUpFormSchema = z
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const verifyEmailFormSchema = z.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
});
|
||||
|
||||
const SignIn = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||
'signIn',
|
||||
);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -90,12 +108,17 @@ const SignIn = () => {
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
email,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||
resolver: zodResolver(verifyEmailFormSchema),
|
||||
defaultValues: { code },
|
||||
});
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
@@ -103,13 +126,12 @@ const SignIn = () => {
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData);
|
||||
signInForm.reset();
|
||||
router.push('/');
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
toast.error('Error signing in.');
|
||||
} finally {
|
||||
signInForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -124,17 +146,107 @@ const SignIn = () => {
|
||||
try {
|
||||
if (values.confirmPassword !== values.password)
|
||||
throw new ConvexError('Passwords do not match.');
|
||||
await signIn('password', formData);
|
||||
signUpForm.reset();
|
||||
router.push('/');
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('email-verification');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
toast.error('Error signing up.');
|
||||
} finally {
|
||||
signUpForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (
|
||||
values: z.infer<typeof verifyEmailFormSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('flow', flow);
|
||||
formData.append('email', email);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
} catch (error) {
|
||||
console.error('Error verifying email:', error);
|
||||
toast.error('Error verifying email.');
|
||||
} finally {
|
||||
verifyEmailForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (flow === 'email-verification') {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<CardContent>
|
||||
<div className='text-center mb-6'>
|
||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||
</div>
|
||||
<Form {...verifyEmailForm}>
|
||||
<form
|
||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSeparator />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your email.
|
||||
</FormDescription>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Verify Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='text-center mt-4'>
|
||||
<button
|
||||
onClick={() => setFlow('signUp')}
|
||||
className='text-sm text-muted-foreground hover:underline'
|
||||
>
|
||||
Back to Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
@@ -211,12 +323,28 @@ const SignIn = () => {
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex justify-center'>
|
||||
<div
|
||||
className='flex flex-row items-center
|
||||
my-2.5 mx-auto justify-center w-1/4'
|
||||
>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<MicrosoftSignInButton />
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -309,12 +437,25 @@ const SignIn = () => {
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex my-auto justify-center w-2/3'>
|
||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<MicrosoftSignInButton type='signUp' />
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -38,7 +38,6 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
return (
|
||||
<ConvexClientProvider>
|
||||
<PlausibleProvider
|
||||
domain='techtracker.gbrown.org'
|
||||
customDomain='https://plausible.gbrown.org'
|
||||
@@ -69,7 +68,6 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
</ConvexClientProvider>
|
||||
);
|
||||
};
|
||||
export default GlobalError;
|
||||
@@ -4,6 +4,8 @@ import '@/styles/globals.css';
|
||||
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||
import {
|
||||
ConvexClientProvider,
|
||||
LunchReminder,
|
||||
NotificationsPermission,
|
||||
ThemeProvider,
|
||||
TVModeProvider,
|
||||
} from '@/components/providers';
|
||||
@@ -22,11 +24,11 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
export const metadata: Metadata = generateMetadata();
|
||||
|
||||
export default function RootLayout({
|
||||
const RootLayout = async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}>) => {
|
||||
return (
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<PlausibleProvider
|
||||
@@ -48,6 +50,8 @@ export default function RootLayout({
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
<NotificationsPermission />
|
||||
<LunchReminder />
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
@@ -56,4 +60,5 @@ export default function RootLayout({
|
||||
</PlausibleProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
export default RootLayout;
|
||||
39
apps/next/src/components/layout/auth/buttons/gibs-auth.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
2
apps/next/src/components/layout/auth/buttons/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GibsAuthSignInButton } from './gibs-auth';
|
||||
export { MicrosoftSignInButton } from './microsoft';
|
||||
39
apps/next/src/components/layout/auth/buttons/microsoft.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -11,16 +11,24 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
|
||||
if (tvMode)
|
||||
return (
|
||||
<div className='absolute top-16 right-20'>
|
||||
<header
|
||||
{...headerProps}
|
||||
className={cn(
|
||||
'w-full px-4 md:px-6 lg:px-20 my-8',
|
||||
headerProps?.className,
|
||||
)}
|
||||
>
|
||||
<div className='flex-1 flex justify-end mt-5'>
|
||||
<Controls />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
{...headerProps}
|
||||
className={cn(
|
||||
'w-full min-h-[10vh] px-4 md:px-6 lg:px-20 my-8',
|
||||
'w-full px-4 md:px-6 lg:px-20 my-8',
|
||||
headerProps?.className,
|
||||
)}
|
||||
>
|
||||
@@ -40,10 +48,10 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
alt='Tech Tracker Logo'
|
||||
width={100}
|
||||
height={100}
|
||||
className='max-w-[40px] md:max-w-[120px]'
|
||||
className='w-10 md:w-[120px]'
|
||||
/>
|
||||
<h1
|
||||
className='title-text text-sm md:text-4xl lg:text-8xl
|
||||
className='title-text text-base md:text-4xl lg:text-8xl
|
||||
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
|
||||
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
|
||||
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
||||
234
apps/next/src/components/layout/profile/avatar-upload.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { type ChangeEvent, useRef, useState } from 'react';
|
||||
import {
|
||||
type Preloaded,
|
||||
usePreloadedQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
Button,
|
||||
CardContent,
|
||||
ImageCrop,
|
||||
ImageCropApply,
|
||||
ImageCropContent,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
||||
import { type Id } from '~/convex/_generated/dataModel';
|
||||
|
||||
type AvatarUploadProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
const dataUrlToBlob = async (
|
||||
dataUrl: string,
|
||||
): Promise<{ blob: Blob; type: string }> => {
|
||||
const re = /^data:([^;,]+)[;,]/;
|
||||
const m = re.exec(dataUrl);
|
||||
const type = m?.[1] ?? 'image/png';
|
||||
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
return { blob, type };
|
||||
};
|
||||
|
||||
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image } : 'skip',
|
||||
);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file.');
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setCroppedImage(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedFile(null);
|
||||
setCroppedImage(null);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!croppedImage) {
|
||||
toast.error('Please apply a crop first.');
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const { blob, type } = await dataUrlToBlob(croppedImage);
|
||||
const postUrl = await generateUploadUrl();
|
||||
|
||||
const result = await fetch(postUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': type },
|
||||
body: blob,
|
||||
});
|
||||
if (!result.ok) {
|
||||
const msg = await result.text().catch(() => 'Upload failed.');
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const uploadResponse = (await result.json()) as {
|
||||
storageId: Id<'_storage'>;
|
||||
};
|
||||
|
||||
await updateUser({ image: uploadResponse.storageId });
|
||||
|
||||
toast.success('Profile picture updated.');
|
||||
handleReset();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
toast.error('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContent>
|
||||
<div className='flex flex-col items-center gap-4'>
|
||||
{/* Current avatar + trigger (hidden when cropping) */}
|
||||
{!selectedFile && (
|
||||
<div
|
||||
className='relative group cursor-pointer'
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className='h-42 w-42 text-6xl font-semibold'
|
||||
userIconProps={{ size: 100 }}
|
||||
/>
|
||||
<div
|
||||
className='absolute inset-0 rounded-full bg-black/0
|
||||
group-hover:bg-black/50 transition-all flex items-center
|
||||
justify-center'
|
||||
>
|
||||
<Upload
|
||||
className='text-white opacity-0 group-hover:opacity-100
|
||||
transition-opacity'
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='absolute inset-1 transition-all flex items-end
|
||||
justify-end'
|
||||
>
|
||||
<Pencil
|
||||
className='text-white opacity-100 group-hover:opacity-0
|
||||
transition-opacity'
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File input (hidden) */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id='avatar-upload'
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* Crop UI */}
|
||||
{selectedFile && !croppedImage && (
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<ImageCrop
|
||||
aspect={1}
|
||||
circularCrop
|
||||
file={selectedFile}
|
||||
maxImageSize={3 * 1024 * 1024} // 3MB guard
|
||||
onCrop={setCroppedImage}
|
||||
>
|
||||
<ImageCropContent className='max-w-sm' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<ImageCropApply />
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
<XIcon className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</ImageCrop>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cropped preview + actions */}
|
||||
{croppedImage && (
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<Avatar className='h-42 w-42'>
|
||||
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
||||
</Avatar>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isUploading}
|
||||
className='px-4'
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
'Save Avatar'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
type='button'
|
||||
className='dark:bg-red-500/30 bg-red-400/80
|
||||
hover:dark:text-red-300/60 hover:text-red-800/80
|
||||
hover:dark:bg-accent'
|
||||
variant='secondary'
|
||||
>
|
||||
<XIcon className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading indicator */}
|
||||
{isUploading && !croppedImage && (
|
||||
<div className='flex items-center text-sm text-gray-500 mt-2'>
|
||||
<Loader2 className='h-4 w-4 mr-2 animate-spin' />
|
||||
Uploading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { useAction } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { z } from 'zod';
|
||||
import { type Preloaded, usePreloadedQuery } from 'convex/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
@@ -62,7 +64,12 @@ const formSchema = z
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export const ResetPasswordForm = () => {
|
||||
type ResetFormProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changePassword = useAction(api.auth.updateUserPassword);
|
||||
@@ -94,10 +101,12 @@ export const ResetPasswordForm = () => {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
return user?.provider !== 'password' ? (
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
<CardHeader className='pb-5'>
|
||||
<Separator />
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
208
apps/next/src/components/layout/profile/user-info.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
SubmitButton,
|
||||
Switch,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, {
|
||||
message: 'Full name is required & must be at least 5 characters.',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Full name must be less than 50 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
lunchTime: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Must be a valid 24-hour time. Example: 13:00' }),
|
||||
automaticLunch: z.boolean(),
|
||||
});
|
||||
|
||||
type UserInfoFormProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
||||
() => ({
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
lunchTime: user?.lunchTime ?? '12:00',
|
||||
automaticLunch: user?.automaticLunch ?? false,
|
||||
}),
|
||||
[user?.name, user?.email, user?.lunchTime, user?.automaticLunch],
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: initialValues,
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const lunchTime = values.lunchTime.trim();
|
||||
const automaticLunch = values.automaticLunch;
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
lunchTime: string;
|
||||
automaticLunch: boolean;
|
||||
}> = {};
|
||||
if (name !== (user?.name ?? '') && name !== undefined)
|
||||
patch.name = name;
|
||||
if (email !== (user?.email ?? '') && email !== undefined)
|
||||
patch.email = email;
|
||||
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
|
||||
patch.lunchTime = lunchTime;
|
||||
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
|
||||
patch.automaticLunch = automaticLunch;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(patch);
|
||||
form.reset(patch);
|
||||
toast.success('Profile updated successfully.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Error updating profile.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl'>Account Information</CardTitle>
|
||||
<CardDescription>Update your account information here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={user?.provider !== 'password'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex flex-row justify-center space-x-10'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='lunchTime'
|
||||
render={({ field }) => (
|
||||
<FormItem className='sm:w-2/5'>
|
||||
<div className='flex flex-row space-x-2 my-auto'>
|
||||
<FormLabel>Lunch Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='time' className='w-28' {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormDescription>Your regular lunch time.</FormDescription>
|
||||
<FormDescription className='dark:text-red-300/60 text-red-800/80'>
|
||||
{!user?.lunchTime && 'Not currently set.'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='automaticLunch'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-2/5 mt-2'>
|
||||
<div className='flex flex-row space-x-2 my-auto'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className='border-solid border-primary'
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Automatic Lunch</FormLabel>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Automatically take your lunch at the time you specify.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-center mt-5'>
|
||||
<SubmitButton
|
||||
className='lg:w-1/3 w-2/3 text-[1.0rem]'
|
||||
disabled={loading}
|
||||
pendingText='Saving...'
|
||||
>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
222
apps/next/src/components/layout/status/list/history/index.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { formatDate, formatTime } from '@/lib/utils';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
ScrollArea,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export const HistoryTable = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [cursors, setCursors] = useState<(string | null)[]>([null]);
|
||||
|
||||
const args = useMemo(() => {
|
||||
return {
|
||||
paginationOpts: {
|
||||
numItems: PAGE_SIZE,
|
||||
cursor: cursors[pageIndex] ?? null,
|
||||
},
|
||||
};
|
||||
}, [cursors, pageIndex]);
|
||||
|
||||
const data = useQuery(api.statuses.listHistory, args);
|
||||
const isLoading = data === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const nextIndex = pageIndex + 1;
|
||||
setCursors((prev) => {
|
||||
const copy = [...prev];
|
||||
if (copy[nextIndex] === undefined) {
|
||||
copy[nextIndex] = data.continueCursor;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}, [data, pageIndex]);
|
||||
|
||||
const canPrev = pageIndex > 0;
|
||||
const canNext = !!data && data.continueCursor !== null;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!canPrev) return;
|
||||
setPageIndex((p) => Math.max(0, p - 1));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!canNext) return;
|
||||
setPageIndex((p) => p + 1);
|
||||
};
|
||||
|
||||
const rows = data?.page ?? [];
|
||||
|
||||
return (
|
||||
<div className='w-full px-4 sm:px-6'>
|
||||
{/* Mobile: card list */}
|
||||
<div className='md:hidden'>
|
||||
<ScrollArea className='max-h-[70vh] w-full'>
|
||||
{isLoading ? (
|
||||
<div className='flex justify-center items-center h-32'>
|
||||
<div
|
||||
className='animate-spin rounded-full h-8 w-8
|
||||
border-b-2 border-primary'
|
||||
/>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className='flex justify-center items-center h-32'>
|
||||
<p className='text-muted-foreground'>No history found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2 pb-2'>
|
||||
{rows.map((r, idx) => {
|
||||
const key = `${r.status?.id ?? 'no-status'}-${idx}`;
|
||||
const name = r.user.name ?? 'Technician';
|
||||
const msg = r.status?.message ?? 'No status';
|
||||
const updatedBy = r.status?.updatedBy?.name ?? null;
|
||||
const stamp = r.status
|
||||
? `${formatTime(r.status.updatedAt)} · ${formatDate(
|
||||
r.status.updatedAt,
|
||||
)}`
|
||||
: '--:-- · --/--';
|
||||
|
||||
return (
|
||||
<div key={key} className='rounded-lg border p-3'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='min-w-0'>
|
||||
<div className='font-medium truncate'>{name}</div>
|
||||
<div
|
||||
className='text-sm text-muted-foreground
|
||||
mt-0.5 line-clamp-2 break-words'
|
||||
title={msg}
|
||||
>
|
||||
{msg}
|
||||
</div>
|
||||
</div>
|
||||
{updatedBy && (
|
||||
<span
|
||||
className='ml-3 shrink-0 rounded
|
||||
bg-muted px-2 py-0.5 text-xs
|
||||
text-foreground'
|
||||
title={`Updated by ${updatedBy}`}
|
||||
>
|
||||
{updatedBy}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='mt-2 flex items-center gap-2
|
||||
text-xs text-muted-foreground'
|
||||
>
|
||||
<span>{stamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Desktop: original table */}
|
||||
<div className='hidden md:block'>
|
||||
<ScrollArea className='h-[600px] w-full px-4'>
|
||||
{isLoading ? (
|
||||
<div className='flex justify-center items-center h-32'>
|
||||
<div
|
||||
className='animate-spin rounded-full h-8 w-8
|
||||
border-b-2 border-primary'
|
||||
/>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className='flex justify-center items-center h-32'>
|
||||
<p className='text-muted-foreground'>No history found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='font-semibold'>Name</TableHead>
|
||||
<TableHead className='font-semibold'>Status</TableHead>
|
||||
<TableHead className='font-semibold'>Updated By</TableHead>
|
||||
<TableHead className='font-semibold text-right'>
|
||||
Date & Time
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r, idx) => (
|
||||
<TableRow key={`${r.status?.id ?? 'no-status'}-${idx}`}>
|
||||
<TableCell className='font-medium'>
|
||||
{r.user.name ?? 'Technician'}
|
||||
</TableCell>
|
||||
<TableCell className='max-w-xs'>
|
||||
<div className='truncate' title={r.status?.message ?? ''}>
|
||||
{r.status?.message ?? 'No status'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-sm text-muted-foreground'>
|
||||
{r.status?.updatedBy?.name ?? ''}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-sm'>
|
||||
{r.status
|
||||
? `${formatTime(r.status.updatedAt)} · ${formatDate(
|
||||
r.status.updatedAt,
|
||||
)}`
|
||||
: '--:-- · --/--'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className='mt-3 sm:mt-4'>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationPrevious
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrev();
|
||||
}}
|
||||
aria-disabled={!canPrev}
|
||||
className={!canPrev ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
<div
|
||||
className='flex items-center gap-2 text-sm
|
||||
text-muted-foreground'
|
||||
>
|
||||
<span>Page</span>
|
||||
<span className='font-bold text-foreground'>{pageIndex + 1}</span>
|
||||
</div>
|
||||
<PaginationNext
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}}
|
||||
aria-disabled={!canNext}
|
||||
className={!canNext ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
557
apps/next/src/components/layout/status/list/index.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { type Id } from '~/convex/_generated/dataModel';
|
||||
import { useTVMode } from '@/components/providers';
|
||||
import {
|
||||
BasedAvatar,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
Input,
|
||||
Label,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { StatusHistory } from '@/components/layout/status';
|
||||
import { HistoryTable } from '@/components/layout/status/list/history';
|
||||
|
||||
type StatusListProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
|
||||
};
|
||||
|
||||
export const StatusList = ({
|
||||
preloadedUser,
|
||||
preloadedStatuses,
|
||||
}: StatusListProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const statuses = usePreloadedQuery(preloadedStatuses);
|
||||
const { tvMode } = useTVMode();
|
||||
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [persistStatus, setPersistStatus] = useState(false);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
|
||||
const [previousStatuses, setPreviousStatuses] = useState(statuses);
|
||||
|
||||
const bulkCreate = useMutation(api.statuses.bulkCreate);
|
||||
|
||||
useEffect(() => {
|
||||
const newAnimatingIds = new Set<string>();
|
||||
statuses.forEach((curr) => {
|
||||
const previous = previousStatuses.find((p) => p.user.id === curr.user.id);
|
||||
if (previous?.status?.updatedAt !== curr.status?.updatedAt) {
|
||||
newAnimatingIds.add(curr.user.id);
|
||||
}
|
||||
});
|
||||
if (newAnimatingIds.size > 0) {
|
||||
setAnimatingIds(newAnimatingIds);
|
||||
setTimeout(() => setAnimatingIds(new Set()), 800);
|
||||
}
|
||||
setPreviousStatuses(
|
||||
statuses
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
|
||||
),
|
||||
);
|
||||
}, [statuses]);
|
||||
|
||||
const handleSelectUser = (id: Id<'users'>) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.some((i) => i === id)
|
||||
? prev.filter((prevId) => prevId !== id)
|
||||
: [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectAll) setSelectedUserIds([]);
|
||||
else setSelectedUserIds(statuses.map((s) => s.user.id));
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
const message = statusInput.trim();
|
||||
setUpdatingStatus(true);
|
||||
try {
|
||||
if (message.length < 3 || message.length > 80) {
|
||||
throw new Error('Status must be between 3 & 80 characters');
|
||||
}
|
||||
if (selectedUserIds.length === 0 && user?.id) {
|
||||
await bulkCreate({
|
||||
message,
|
||||
userIds: [user.id],
|
||||
persistentStatus: persistStatus
|
||||
});
|
||||
} else {
|
||||
await bulkCreate({
|
||||
message,
|
||||
userIds: selectedUserIds,
|
||||
persistentStatus: persistStatus
|
||||
});
|
||||
}
|
||||
toast.success('Status updated.');
|
||||
toast.success('Status updated.', {
|
||||
duration: 2000,
|
||||
closeButton: true,
|
||||
dismissible: true,
|
||||
});
|
||||
setSelectedUserIds([]);
|
||||
setSelectAll(false);
|
||||
setStatusInput('');
|
||||
} catch (error) {
|
||||
toast.error(`Update failed. ${error as Error}`);
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusAge = (updatedAt: number) => {
|
||||
const diff = Date.now() - updatedAt;
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
if (hours > 0) return `${hours}h ${minutes}m ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'Just now';
|
||||
};
|
||||
|
||||
const containerCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'mx-auto',
|
||||
on: 'px-6',
|
||||
off: 'max-w-4xl px-4 sm:px-6',
|
||||
});
|
||||
|
||||
const tabsCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'w-full py-4 sm:py-8',
|
||||
on: 'hidden',
|
||||
off: '',
|
||||
});
|
||||
|
||||
const headerCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'w-full mb-2',
|
||||
on: 'hidden',
|
||||
off: 'hidden sm:flex justify-end items-center',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerCn}>
|
||||
<Tabs defaultValue='status'>
|
||||
<TabsList className={tabsCn}>
|
||||
<TabsTrigger value='status' className='py-3 sm:py-8'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<Activity className='text-primary sm:scale-150' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>Status List</h1>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='history' className='py-3 sm:py-8'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<History className='text-primary sm:scale-150' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>
|
||||
Status History
|
||||
</h1>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='status'>
|
||||
{/* Mobile toolbar */}
|
||||
<div className='sm:hidden mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Users className='w-4 h-4' />
|
||||
<span className='text-sm'>{statuses.length} members</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='outline' size='sm' onClick={handleSelectAll}>
|
||||
{selectAll ? 'Clear' : 'Select all'}
|
||||
</Button>
|
||||
<Link
|
||||
href='/table'
|
||||
className='text-sm font-medium hover:underline'
|
||||
>
|
||||
Table
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className={headerCn}>
|
||||
<div className='flex w-full justify-between px-4'>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
||||
<span>{statuses.length} members</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-xs'>
|
||||
<Link href='/table' className='font-medium hover:underline'>
|
||||
Miss the old table?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
|
||||
{previousStatuses.map((statusData) => {
|
||||
const { user: u, status: s } = statusData;
|
||||
const isSelected = selectedUserIds.includes(u.id);
|
||||
const isAnimating = animatingIds.has(u.id);
|
||||
const isUpdatedByOther = s?.updatedBy?.id !== u.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`
|
||||
relative rounded-xl border transition-all
|
||||
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
|
||||
${s?.persistentStatus ? 'bg-black/10' : ''}
|
||||
${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
}
|
||||
${tvMode ? 'p-5' : 'p-3 sm:p-4'}
|
||||
${!tvMode ? 'active:scale-[0.99]' : ''}
|
||||
`}
|
||||
onClick={!tvMode ? () => handleSelectUser(u.id) : undefined}
|
||||
role='button'
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{isSelected && !tvMode && (
|
||||
<div className='absolute top-3 right-3'>
|
||||
<CheckCircle2 className='w-5 h-5 text-primary' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-start gap-3 sm:gap-4'>
|
||||
{/* Avatar */}
|
||||
<div className='shrink-0'>
|
||||
<BasedAvatar
|
||||
src={u.imageUrl}
|
||||
fullName={u.name ?? 'User'}
|
||||
className={`
|
||||
transition-all duration-300
|
||||
${tvMode ? 'w-36 h-36 text-4xl' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
||||
${isAnimating ? 'ring-primary/30 ring-4' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
|
||||
<h3
|
||||
className={`font-semibold
|
||||
${tvMode ? 'text-5xl' : 'text-base sm:text-xl'}
|
||||
`}
|
||||
title={u.name ?? u.email ?? 'User'}
|
||||
>
|
||||
{u.name ?? u.email ?? 'User'}
|
||||
</h3>
|
||||
{isUpdatedByOther && s?.updatedBy && (
|
||||
<div
|
||||
className='hidden sm:flex items-center gap-2
|
||||
text-muted-foreground min-w-0'
|
||||
>
|
||||
<span
|
||||
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
|
||||
>
|
||||
via
|
||||
</span>
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name ?? 'User'}
|
||||
className={`${tvMode ? 'w-14 h-14 text-xl' : 'w-6 h-6'}`}
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-4xl' : 'truncate'}`}
|
||||
>
|
||||
{s.updatedBy.name ??
|
||||
s.updatedBy.email ??
|
||||
'another user'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
mb-2 sm:mb-3
|
||||
${tvMode ? 'text-6xl' : 'text-[0.95rem] sm:text-lg'}
|
||||
${s ? 'text-foreground' : 'text-muted-foreground italic'}
|
||||
`}
|
||||
title={s?.message ?? undefined}
|
||||
>
|
||||
{s?.message ?? 'No status yet.'}
|
||||
</div>
|
||||
{/* Meta - only show here when NOT in TV mode */}
|
||||
{!tvMode && (
|
||||
<div className='flex items-center text-muted-foreground gap-3 sm:gap-4'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Calendar className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||
</span>
|
||||
</div>
|
||||
{s && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Activity className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{getStatusAge(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/Time Column - only show when in TV mode */}
|
||||
{tvMode && (
|
||||
<div className='flex flex-col items-end gap-2 text-muted-foreground min-w-0'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Clock className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Calendar className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||
</span>
|
||||
</div>
|
||||
{s && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Activity className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
{getStatusAge(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Actions */}
|
||||
{!tvMode && (
|
||||
<div className='flex flex-col items-end gap-2'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 px-2 sm:px-3'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<History className='w-4 h-4 sm:mr-2' />
|
||||
<span className='hidden sm:inline'>History</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<StatusHistory user={u} />
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile "via user" line */}
|
||||
{isUpdatedByOther && s?.updatedBy && (
|
||||
<div
|
||||
className='sm:hidden mt-2 flex items-center gap-2
|
||||
text-muted-foreground'
|
||||
>
|
||||
<span className='text-xs'>via</span>
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name ?? 'User'}
|
||||
className='w-4 h-4'
|
||||
/>
|
||||
<span className='text-xs truncate'>
|
||||
{s.updatedBy.name ??
|
||||
s.updatedBy.email ??
|
||||
'another user'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop composer */}
|
||||
{!tvMode && (
|
||||
<Card
|
||||
className='mt-5 hidden md:block border-2 border-dashed
|
||||
border-muted-foreground/20 hover:border-primary/50
|
||||
transition-colors'
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex gap-3 w-full justify-between'>
|
||||
<div className='flex gap-3 items-center'>
|
||||
<Zap className='w-6 h-6 text-primary' />
|
||||
<h3 className='text-xl font-semibold'>Update Status</h3>
|
||||
{selectedUserIds.length > 0 && (
|
||||
<span
|
||||
className='px-2 py-1 bg-primary/10 text-primary
|
||||
text-sm rounded-full'
|
||||
>
|
||||
{selectedUserIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex space-x-2 items-center'>
|
||||
<Checkbox
|
||||
checked={persistStatus}
|
||||
className='border border-primary'
|
||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
||||
/>
|
||||
<Label>
|
||||
Persist Status
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-3'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder="What's happening?"
|
||||
className='flex-1 text-lg h-12'
|
||||
value={statusInput}
|
||||
disabled={updatingStatus}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!updatingStatus
|
||||
) {
|
||||
e.preventDefault();
|
||||
void handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updatingStatus}
|
||||
className='px-6 h-12'
|
||||
>
|
||||
{selectedUserIds.length > 0
|
||||
? `Update ${selectedUserIds.length} ${
|
||||
selectedUserIds.length > 1 ? 'users' : 'user'
|
||||
}`
|
||||
: 'Update Status'}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectAll ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-sm text-muted-foreground'>
|
||||
{statusInput.length}/80 characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mobile sticky composer */}
|
||||
{!tvMode && (
|
||||
<div
|
||||
className='md:hidden fixed bottom-0 left-0 right-0 z-50
|
||||
border-t bg-background/95 backdrop-blur
|
||||
supports-backdrop-filter:bg-background/60 p-3
|
||||
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
{selectedUserIds.length > 0 ? (
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{selectedUserIds.length} selected
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
Update your status
|
||||
</span>
|
||||
)}
|
||||
<div className='flex flex-row space-x-2'>
|
||||
<Checkbox
|
||||
className='border border-primary'
|
||||
checked={persistStatus}
|
||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
||||
/>
|
||||
<Label className='text-xs'>
|
||||
Persist Status
|
||||
</Label>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' onClick={handleSelectAll}>
|
||||
{selectAll ? 'Clear' : 'Select all'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder="What's happening?"
|
||||
className='h-11 text-base'
|
||||
value={statusInput}
|
||||
disabled={updatingStatus}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) {
|
||||
e.preventDefault();
|
||||
void handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updatingStatus}
|
||||
className='h-11 px-4'
|
||||
>
|
||||
Update
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='history'>
|
||||
<HistoryTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
303
apps/next/src/components/layout/status/table/index.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { type Id } from '~/convex/_generated/dataModel';
|
||||
import { useTVMode } from '@/components/providers';
|
||||
import {
|
||||
BasedAvatar,
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
Input,
|
||||
SubmitButton,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
||||
import { Clock, Calendar } from 'lucide-react';
|
||||
import { StatusHistory } from '@/components/layout/status';
|
||||
|
||||
type StatusTableProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
|
||||
};
|
||||
|
||||
export const StatusTable = ({
|
||||
preloadedUser,
|
||||
preloadedStatuses,
|
||||
}: StatusTableProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const statuses = usePreloadedQuery(preloadedStatuses);
|
||||
|
||||
const { tvMode } = useTVMode();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
|
||||
const bulkCreate = useMutation(api.statuses.bulkCreate);
|
||||
|
||||
const handleSelectUser = (id: Id<'users'>) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.some((i) => i === id)
|
||||
? prev.filter((prevId) => prevId !== id)
|
||||
: [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectAll) setSelectedUserIds([]);
|
||||
else setSelectedUserIds(statuses.map((s) => s.user.id));
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
const message = statusInput.trim();
|
||||
setUpdatingStatus(true);
|
||||
try {
|
||||
if (message.length < 3 || message.length > 80)
|
||||
throw new Error('Status must be between 3 & 80 characters');
|
||||
if (selectedUserIds.length === 0 && user?.id)
|
||||
await bulkCreate({ message, userIds: [user.id] });
|
||||
await bulkCreate({ message, userIds: selectedUserIds });
|
||||
toast.success('Status updated.');
|
||||
setSelectedUserIds([]);
|
||||
setSelectAll(false);
|
||||
setStatusInput('');
|
||||
} catch (error) {
|
||||
toast.error(`Update failed. ${error as Error}`);
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const containerCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'mx-auto',
|
||||
on: 'lg:w-11/12 w-full',
|
||||
off: 'w-5/6',
|
||||
});
|
||||
const headerCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'w-full mb-2 flex justify-between',
|
||||
on: '',
|
||||
off: 'mb-2',
|
||||
});
|
||||
const thCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'py-4 px-4 border font-semibold ',
|
||||
on: 'lg:text-6xl xl:min-w-[420px]',
|
||||
off: 'lg:text-5xl xl:min-w-[320px]',
|
||||
});
|
||||
const tdCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'py-2 px-2 border',
|
||||
on: 'lg:text-5xl',
|
||||
off: 'lg:text-4xl',
|
||||
});
|
||||
const tCheckboxCn = `py-3 px-4 border`;
|
||||
const checkBoxCn = `lg:scale-200 cursor-pointer`;
|
||||
|
||||
return (
|
||||
<div className={containerCn}>
|
||||
<div className={headerCn}>
|
||||
<div className='flex items-center gap-2'>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2 text-xs'>
|
||||
<p className='text-muted-foreground'>Tired of the old table? </p>
|
||||
<Link
|
||||
href='/'
|
||||
className='italic font-semibold hover:text-primary/80'
|
||||
>
|
||||
Try the new status list!
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<table className='w-full text-center rounded-md'>
|
||||
<thead>
|
||||
<tr className='dark:bg-muted bg-accent/30'>
|
||||
{!tvMode && (
|
||||
<th className={tCheckboxCn}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxCn}
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className={thCn}>Technician</th>
|
||||
<th className={thCn}>
|
||||
<Drawer>
|
||||
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
|
||||
Status
|
||||
</DrawerTrigger>
|
||||
<StatusHistory />
|
||||
</Drawer>
|
||||
</th>
|
||||
<th className={thCn}>Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statuses.map((status, i) => {
|
||||
const { user: u, status: s } = status;
|
||||
const isSelected = selectedUserIds.includes(u.id);
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className={`
|
||||
${
|
||||
i % 2 === 0
|
||||
? 'dark:bg-muted/20 bg-muted'
|
||||
: 'dark:bg-muted/80 bg-accent/50'
|
||||
}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
>
|
||||
{!tvMode && (
|
||||
<td className={tCheckboxCn}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxCn}
|
||||
checked={isSelected}
|
||||
onChange={() => handleSelectUser(u.id)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className={tdCn}>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BasedAvatar
|
||||
src={u.imageUrl}
|
||||
fullName={u.name}
|
||||
className={tvMode ? 'w-16 h-16 text-2xl' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
|
||||
{s?.updatedBy && s.updatedBy.id !== u.id && (
|
||||
<div className='flex items-center gap-1 text-muted-foreground'>
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name}
|
||||
className='w-5 h-5 text-xs'
|
||||
/>
|
||||
<span className={tvMode ? 'text-xl' : 'text-base'}>
|
||||
Updated by {s.updatedBy.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tdCn}>
|
||||
<Drawer>
|
||||
<DrawerTrigger>{s?.message}</DrawerTrigger>
|
||||
<StatusHistory user={u} />
|
||||
</Drawer>
|
||||
</td>
|
||||
<td className={tdCn}>
|
||||
<Drawer>
|
||||
<DrawerTrigger>
|
||||
<div className='flex w-full'>
|
||||
<div className='flex flex-col my-auto items-start'>
|
||||
<div className='flex gap-4 my-1'>
|
||||
<Clock
|
||||
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
|
||||
/>
|
||||
<p
|
||||
className={`${tvMode ? 'text-4xl' : 'text-3xl'}`}
|
||||
>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex gap-4 my-1'>
|
||||
<Calendar
|
||||
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
|
||||
/>
|
||||
<p
|
||||
className={`${tvMode ? 'text-4xl' : 'text-3xl'}`}
|
||||
>
|
||||
{s ? formatDate(s.updatedAt) : '--:--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<StatusHistory user={u} />
|
||||
</Drawer>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{statuses.length === 0 && (
|
||||
<div className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!tvMode && (
|
||||
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='New Status'
|
||||
className={
|
||||
'min-w-[120px] lg:max-w-[400px] py-6 px-3 rounded-xl \
|
||||
border bg-background lg:text-2xl focus:outline-none \
|
||||
focus:ring-2 focus:ring-primary'
|
||||
}
|
||||
value={statusInput}
|
||||
disabled={updatingStatus}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) {
|
||||
e.preventDefault();
|
||||
void handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton
|
||||
className={
|
||||
'px-8 rounded-xl font-semibold lg:text-2xl \
|
||||
disabled:opacity-50 disabled:cursor-not-allowed \
|
||||
cursor-pointer'
|
||||
}
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updatingStatus}
|
||||
pendingText='Updating...'
|
||||
>
|
||||
{selectedUserIds.length > 0
|
||||
? `Update status for ${selectedUserIds.length}
|
||||
${selectedUserIds.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update status'}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Status History Drawer */}
|
||||
{!tvMode && (
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={tvMode ? 'text-3xl p-6' : ''}
|
||||
>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<StatusHistory />
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
export { LunchReminder } from './lunch-reminder';
|
||||
export { NotificationsPermission } from './notification-permission';
|
||||
export {
|
||||
ThemeProvider,
|
||||
ThemeToggle,
|
||||
76
apps/next/src/components/providers/lunch-reminder.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'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;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
'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;
|
||||
};
|
||||