Compare commits

..

58 Commits

Author SHA1 Message Date
6c523b8246 Trying to match server before I throw away all of those changes as well 2025-10-27 16:04:01 -05:00
d2517901a8 Added persistent statuses. 2025-10-27 16:00:45 -05:00
4306d69558 Remove cron test 2025-10-23 15:45:30 -05:00
1040250928 update packages 2025-10-23 15:37:01 -05:00
7eb3a1dff0 Add automatic lunch feature. Clean up some code. 2025-10-23 15:34:09 -05:00
40489be8e9 More ui stuff 2025-09-25 11:29:18 -05:00
467d452bb4 make tv mode list work better 2025-09-25 11:17:34 -05:00
db7bf75815 Make some small fixes to table 2025-09-24 14:10:56 -05:00
bd4e757318 Revert back to old table as AI did a terrible job. 2025-09-24 14:01:48 -05:00
ab278c2ae8 Finally have all email verification / password reset auth flows working! 2025-09-24 13:52:11 -05:00
914c45dca4 Update & format 2025-09-23 09:53:02 -05:00
70924f84a9 Add Usesend but havent tested it just yet 2025-09-23 07:41:39 -05:00
4fe32474df fix 2025-09-20 15:21:02 -05:00
288f464f6a fix 2025-09-20 14:46:05 -05:00
14017faa07 Unmask Sentry data for app as none of it is sensitive. 2025-09-20 14:41:28 -05:00
1ee287b26c add sentry config to see text and media 2025-09-20 10:02:27 -05:00
e286d02c26 add env variables for convex 2025-09-20 09:53:17 -05:00
d4d690eb15 not even sure 2025-09-20 09:49:54 -05:00
a8bbfebd00 scheduling functions now works 2025-09-19 21:25:37 -05:00
f93b39d7a9 Update & format 2025-09-19 18:12:55 -05:00
45d2461781 Want to remove reset password field if provider is not password. almost there 2025-09-19 16:57:59 -05:00
fd2999e9bb I can't believe it but I have sign in with Microsoft working! 2025-09-19 16:13:38 -05:00
e7cdf7f754 Never got Microsoft Sign in working, but we have my auth at least 2025-09-19 11:29:35 -05:00
6249d51311 Added Authentik! Still working on Microsoft Entra ID 2025-09-18 17:01:28 -05:00
3092ada03a Added Authentik! Still working on Microsoft Entra ID 2025-09-18 17:00:38 -05:00
8677bee1a9 Before trying to add oauth with convex auth 2025-09-18 14:57:27 -05:00
87c128f7c5 Update form to look better & add automatic lunch toggle 2025-09-17 21:22:16 -05:00
3d85e0c2e9 Update form to look better & add automatic lunch toggle 2025-09-17 21:13:02 -05:00
3bff31c07a Fix more bugs with lunch reminder 2025-09-17 16:40:38 -05:00
d4842fdacd Trying to fix some whack ass bugs 2025-09-17 16:08:28 -05:00
92854382db Lunch time reminder is now actually functional 2025-09-17 15:56:59 -05:00
84bfc21877 starting to add stuff for lunch time reminders 2025-09-17 15:25:23 -05:00
b737fa22c3 fix the gosh dang automatic status update hopefully forreal this time! 2025-09-16 22:04:36 -05:00
7e7e92b89a Remove sentry example page. Fix global error page 2025-09-16 14:56:41 -05:00
3a0263b96b Fix issue preventing build 2025-09-16 13:08:06 -05:00
926f31b3aa update sentry config to new self hosted sentry 2025-09-16 10:27:32 -05:00
83e1a41ab8 Update packages. Format code with prettier 2025-09-16 06:58:47 -05:00
d918a3d01a Add convex 2025-09-15 20:09:36 -05:00
641abf7801 Expo app can now run 2025-09-15 16:54:46 -05:00
ddce36c366 Update expo project to use stuff its gonna use 2025-09-15 16:21:49 -05:00
c218d2edc2 Update packages 2025-09-13 19:21:28 -05:00
1e4df157e9 Hopefully this actually removes all sensitive data from commit history 2025-09-13 04:30:08 -05:00
4d81e1f8e6 Almost hostable from monorepo 2025-09-12 17:21:38 -05:00
b1eae564be Move to monorepo for React Native! 2025-09-12 16:44:21 -05:00
4cafc11422 Make mobile better 2025-09-11 13:21:57 -05:00
0ce699d44c format and update 2025-09-11 12:28:13 -05:00
136047ca25 Added scheduled end of shift message & cleaned up tv mode layout 2025-09-11 12:27:21 -05:00
52be5c93f4 Update packages. 2025-09-10 19:53:15 -05:00
bf12031773 stopping point 2025-09-10 10:11:46 -05:00
603a23983c working on table now 2025-09-08 17:00:37 -05:00
37c3767c71 Added cropping for pfp 2025-09-08 16:21:36 -05:00
3eff470a80 Formatting and installed image crop tool 2025-09-08 15:48:25 -05:00
7d110bee8e okay now we good 2025-09-08 15:03:50 -05:00
ea5712bdfa chmod update script 2025-09-08 14:53:46 -05:00
7b78c4a658 This should let us spin up dev mode easy 2025-09-08 14:52:14 -05:00
fa447c42cb idek 2025-09-08 14:35:31 -05:00
5485f3d28f Make it so that we can have dev env for convex too 2025-09-08 12:51:21 -05:00
c227d42e5e Add environment variables to docker config so we can have a dev server 2025-09-08 12:19:55 -05:00
180 changed files with 7449 additions and 1850 deletions

View File

@@ -1,8 +0,0 @@
.git
node_modules
.next
dist
coverage
*.log
docker-compose*.yml
host/

46
.gitignore vendored
View File

@@ -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

View File

@@ -2,6 +2,7 @@
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all",
"tabWidth": 2
"tabWidth": 2,
"arrowParens": "always"
}

View File

@@ -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
```
@@ -62,32 +67,6 @@ Run
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!

3
app.json Normal file
View File

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

43
apps/expo/.gitignore vendored Normal file
View 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
View 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
}
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View 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/*'],
},
]);

View 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
View 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
}

View 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>
);
}

View 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&apos;s current color scheme is, and so
you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href='https://docs.expo.dev/develop/user-interface/color-themes/'>
<ThemedText type='link'>Learn more</ThemedText>
</ExternalLink>
</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,
},
});

View 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',
},
});

View 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);

View 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,
},
});

View 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,
});
}
}}
/>
);
}

View 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);
}}
/>
);
}

View 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>
);
}

View 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',
},
});

View 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',
},
});

View 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} />;
}

View 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,
},
});

View 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,
]}
/>
);
}

View 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}
/>
);
}

View 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",
},
});

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View 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';
}

View 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
View 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
View 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

View File

@@ -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=

View 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,
},
},
},
);

View File

@@ -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
View 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"
}
}

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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,
});

View 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;

View 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;

View File

@@ -18,8 +18,7 @@ const Profile = async () => {
<AvatarUpload preloadedUser={preloadedUser} />
<Separator />
<UserInfoForm preloadedUser={preloadedUser} />
<Separator />
<ResetPasswordForm />
<ResetPasswordForm preloadedUser={preloadedUser} />
<Separator />
<SignOutForm />
</Card>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View 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&apos;s Auth</p>
</div>
</Button>
);
};

View File

@@ -0,0 +1,2 @@
export { GibsAuthSignInButton } from './gibs-auth';
export { MicrosoftSignInButton } from './microsoft';

View 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>
);
};

View File

@@ -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'

View 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>
);
};

View File

@@ -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

View 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>
</>
);
};

View 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 &amp; 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -1,4 +1,6 @@
export { ConvexClientProvider } from './ConvexClientProvider';
export { LunchReminder } from './lunch-reminder';
export { NotificationsPermission } from './notification-permission';
export {
ThemeProvider,
ThemeToggle,

View 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;
};

View File

@@ -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;
};

Some files were not shown because too many files have changed in this diff Show More