Compare commits

..

2 Commits

336 changed files with 1055 additions and 3136 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Hosting
/docker/data
/host/convex/docker/data
# dependencies
node_modules

View File

@@ -31,30 +31,29 @@ I would recommend using [bun](https://bun.sh/) to install dependencies.
bun i
```
You will also need docker installed on whatever host you plan to run the Convex instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Convex SaaS if you want to have a much easier time, probably. I wouldn't know!
You will also need docker installed on whatever host you plan to run the Supabase instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Supabase SaaS if you want to have a much easier time, probably. I wouldn't know!
### Add your environment variables
Copy the example environment variable files and paste them in the same directory named `.env`.
Environment variables for Next Application
```bash
cp ./apps/next/env.example ./apps/next/.env
cp ./app/next/env.example ./app/next/.env
```
Environment variables for Self Hosting Convex & Website with Docker
```bash
cp ./docker/env.example ./docker/.env
cp ./host/convex/docker/env.example ./host/convex/docker/.env
```
### Start self hosted convex & Next Web Application
```bash
cp ./host/next/docker/env.example ./host/next/docker/.env
```
### Start self hosted convex
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
```bash
cd ./docker
cd ./host/convex/docker
sudo docker compose up -d
sudo docker compose exec convex-backend ./generate_admin_key.sh
```
@@ -63,10 +62,36 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
Run
```bash
```bash
bun dev
```
to start your development environment with turbopack
You can also run
```bash
bun dev:slow
```
to start your development environment with webpack (This is for the next app.)
### Start your Production Environment.
There are Dockerfiles & docker compose files that can be found in the `./scripts/docker` folder for the Next.js website. There is also a script called `reload_container` located in the `./scripts/` folder which was created to quickly update the container, but this will give you a better idea of what you need to do. First, build the image with
```bash
sudo docker compose -f ./host/next/docker/compose.yml build
```
then you can run the container with
```bash
sudo docker compose -f ./host/next/docker/compose up -d
```
Now, you may end up with some build errors. The `reload_containers` script swaps out the next config before it runs the docker build to skip any build errors, so you may want to do this as well, though you are welcome to fix the build errors as well, of course!
### Fin
I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here!

View File

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

View File

@@ -1,45 +1,25 @@
{
"expo": {
"name": "Tech Tracker",
"owner": "gib",
"name": "techtracker-expo",
"slug": "techtracker-expo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "org.gbrown.techtrackerexpo",
"scheme": "techtrackerexpo",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2e2f3d"
},
"newArchEnabled": true,
"ios": {
"usesAppleSignIn": true,
"supportsTablet": true,
"bundleIdentifier": "com.gbrown.techtracker",
"config": {
"usesNonExemptEncryption": false
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSLocationWhenInUseUsageDescription": "This app uses your location in order to allow you to share your location in chat.",
"NSCameraUsageDescription": "This app uses your camera to take photos & send them in the chat."
}
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#2e2f3d"
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.INTERNET"
],
"package": "com.gbrown.techtracker"
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
@@ -47,7 +27,6 @@
},
"plugins": [
"expo-router",
"expo-apple-authentication",
[
"expo-splash-screen",
{
@@ -59,27 +38,6 @@
"backgroundColor": "#000000"
}
}
],
[
"expo-secure-store",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your FaceID biometric data."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
}
],
[
"@sentry/react-native/expo",
{
"url": "https://sentry.gbrown.org",
"note": "Use SENTRY_AUTH_TOKEN env to authenticate with Sentry.",
"project": "tech-tracker-expo",
"organization": "gib"
}
]
],
"experiments": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -1,5 +0,0 @@
// const { getDefaultConfig } = require("expo/metro-config");
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
// const config = getDefaultConfig(__dirname);
const config = getSentryExpoConfig(__dirname);
module.exports = config;

View File

@@ -3,46 +3,42 @@
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"dev": "expo start",
"dev:tunnel": "expo start --tunnel",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.6.0",
"@react-navigation/elements": "^2.7.1",
"@react-navigation/native": "^7.1.19",
"@sentry/react-native": "^7.4.0",
"expo": "~54.0.20",
"expo-apple-authentication": "~8.0.7",
"expo-constants": "~18.0.10",
"expo-font": "~14.0.9",
"@expo/vector-icons": "^15.0.2",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.4",
"expo-constants": "~18.0.8",
"expo-font": "~14.0.8",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image": "~3.0.8",
"expo-linking": "~8.0.8",
"expo-location": "~19.0.7",
"expo-router": "~6.0.13",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-router": "~6.0.2",
"expo-splash-screen": "~31.0.9",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.3",
"react-native-safe-area-context": "~5.6.1",
"react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2",
"react-native-worklets": "0.5.1"
"react-native-web": "~0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.17",
"@types/react": "~19.1.0",
"eslint-config-expo": "~10.0.0"
},
"private": true

View File

@@ -15,24 +15,19 @@ export default function TabLayout() {
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
}}
>
}}>
<Tabs.Screen
name='index'
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name='house.fill' color={color} />
),
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name='explore'
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name='paperplane.fill' color={color} />
),
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>

View File

@@ -1,5 +1,6 @@
import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native';
import { Collapsible } from '@/components/ui/collapsible';
import { ExternalLink } from '@/components/external-link';
import ParallaxScrollView from '@/components/parallax-scroll-view';
@@ -15,82 +16,71 @@ export default function TabTwoScreen() {
headerImage={
<IconSymbol
size={310}
color='#808080'
name='chevron.left.forwardslash.chevron.right'
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}
>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText
type='title'
type="title"
style={{
fontFamily: Fonts.rounded,
}}
>
}}>
Explore
</ThemedText>
</ThemedView>
<ThemedText>
This app includes example code to help you get started.
</ThemedText>
<Collapsible title='File-based routing'>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
and{' '}
<ThemedText type='defaultSemiBold'>app/(tabs)/explore.tsx</ThemedText>
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in{' '}
<ThemedText type='defaultSemiBold'>app/(tabs)/_layout.tsx</ThemedText>{' '}
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href='https://docs.expo.dev/router/introduction'>
<ThemedText type='link'>Learn more</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title='Android, iOS, and web support'>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the
web version, press <ThemedText type='defaultSemiBold'>w</ThemedText>{' '}
in the terminal running this project.
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title='Images'>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the{' '}
<ThemedText type='defaultSemiBold'>@2x</ThemedText> and{' '}
<ThemedText type='defaultSemiBold'>@3x</ThemedText> suffixes to
provide files for different screen densities
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require('assets/images/react-logo.png')}
source={require('@/assets/images/react-logo.png')}
style={{ width: 100, height: 100, alignSelf: 'center' }}
/>
<ExternalLink href='https://reactnative.dev/docs/images'>
<ThemedText type='link'>Learn more</ThemedText>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title='Light and dark mode components'>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type='defaultSemiBold'>useColorScheme()</ThemedText> hook
lets you inspect what the user&apos;s current color scheme is, and so
you can adjust UI colors accordingly.
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&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 href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title='Animations'>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type='defaultSemiBold'>
components/HelloWave.tsx
</ThemedText>{' '}
component uses the powerful{' '}
<ThemedText type='defaultSemiBold' style={{ fontFamily: Fonts.mono }}>
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{' '}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{' '}
library to create a waving hand animation.
@@ -98,10 +88,7 @@ export default function TabTwoScreen() {
{Platform.select({
ios: (
<ThemedText>
The{' '}
<ThemedText type='defaultSemiBold'>
components/ParallaxScrollView.tsx
</ThemedText>{' '}
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),

View File

@@ -1,5 +1,6 @@
import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native';
import { HelloWave } from '@/components/hello-wave';
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
@@ -12,22 +13,20 @@ export default function HomeScreen() {
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
headerImage={
<Image
source={require('assets/images/partial-react-logo.png')}
source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo}
/>
}
>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type='title'>Welcome!</ThemedText>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type='subtitle'>Step 1: Try it</ThemedText>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit{' '}
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
to see changes. Press{' '}
<ThemedText type='defaultSemiBold'>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{' '}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: 'cmd + d',
android: 'cmd + m',
@@ -38,26 +37,22 @@ export default function HomeScreen() {
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<Link href='/modal'>
<Link href="/modal">
<Link.Trigger>
<ThemedText type='subtitle'>Step 2: Explore</ThemedText>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
<Link.MenuAction
title='Action'
icon='cube'
onPress={() => alert('Action pressed')}
/>
<Link.MenuAction
title='Share'
icon='square.and.arrow.up'
title="Share"
icon="square.and.arrow.up"
onPress={() => alert('Share pressed')}
/>
<Link.Menu title='More' icon='ellipsis'>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction
title='Delete'
icon='trash'
title="Delete"
icon="trash"
destructive
onPress={() => alert('Delete pressed')}
/>
@@ -70,16 +65,13 @@ export default function HomeScreen() {
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type='subtitle'>Step 3: Get a fresh start</ThemedText>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
{`When you're ready, run `}
<ThemedText type='defaultSemiBold'>
npm run reset-project
</ThemedText>{' '}
to get a fresh <ThemedText type='defaultSemiBold'>app</ThemedText>{' '}
directory. This will move the current{' '}
<ThemedText type='defaultSemiBold'>app</ThemedText> to{' '}
<ThemedText type='defaultSemiBold'>app-example</ThemedText>.
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>

View File

@@ -1,44 +1,24 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
import * as Sentry from '@sentry/react-native';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
Sentry.init({
dsn: 'https://ff2e19b7c72ee50463c6c66b5bef7ce0@sentry.gbrown.org/8',
sendDefaultPii: true,
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
});
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
export const unstable_settings = {
anchor: '(tabs)',
};
const RootLayout = () => {
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<ConvexProvider client={convex}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen
name='modal'
options={{ presentation: 'modal', title: 'Modal' }}
/>
</Stack>
<StatusBar style='auto' />
</ThemeProvider>
</ConvexProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
};
export default Sentry.wrap(RootLayout);
}

View File

@@ -7,9 +7,9 @@ import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type='title'>This is a modal</ThemedText>
<Link href='/' dismissTo style={styles.link}>
<ThemedText type='link'>Go to home screen</ThemedText>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);

View File

@@ -1,18 +1,13 @@
import { Href, Link } from 'expo-router';
import {
openBrowserAsync,
WebBrowserPresentationStyle,
} from 'expo-web-browser';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
href: Href & string;
};
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target='_blank'
target="_blank"
{...rest}
href={href}
onPress={async (event) => {

View File

@@ -12,8 +12,7 @@ export function HelloWave() {
},
animationIterationCount: 4,
animationDuration: '300ms',
}}
>
}}>
👋
</Animated.Text>
);

View File

@@ -34,15 +34,11 @@ export default function ParallaxScrollView({
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1],
),
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
@@ -52,15 +48,13 @@ export default function ParallaxScrollView({
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}
>
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}
>
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>

View File

@@ -7,16 +7,8 @@ export type ThemedViewProps = ViewProps & {
darkColor?: string;
};
export function ThemedView({
style,
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
const backgroundColor = useThemeColor(
{ light: lightColor, dark: darkColor },
'background',
);
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@@ -7,10 +7,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({
children,
title,
}: PropsWithChildren & { title: string }) {
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
@@ -19,17 +16,16 @@ export function Collapsible({
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}
>
activeOpacity={0.8}>
<IconSymbol
name='chevron.right'
name="chevron.right"
size={18}
weight='medium'
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type='defaultSemiBold'>{title}</ThemedText>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>

View File

@@ -18,7 +18,7 @@ export function IconSymbol({
<SymbolView
weight={weight}
tintColor={color}
resizeMode='scaleAspectFit'
resizeMode="scaleAspectFit"
name={name}
style={[
{

View File

@@ -5,10 +5,7 @@ import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<
SymbolViewProps['name'],
ComponentProps<typeof MaterialIcons>['name']
>;
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
@@ -40,12 +37,5 @@ export function IconSymbol({
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return (
<MaterialIcons
color={color}
size={size}
name={MAPPING[name]}
style={style}
/>
);
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View File

@@ -47,8 +47,7 @@ export const Fonts = Platform.select({
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded:
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});

View File

@@ -8,7 +8,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];

View File

@@ -3,13 +3,14 @@
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"jsx": "react-jsx",
"esModuleInterop": true,
"paths": {
"assets/*": ["./assets/*"],
"@/*": ["./src/*"],
"~/*": ["../../packages/backend/*"]
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

8
apps/next/.dockerignore Normal file
View File

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

View File

@@ -45,6 +45,3 @@ next-env.d.ts
# Ignored for the template, you probably want to remove it:
package-lock.json
# Sentry Config File
.env.sentry-build-plugin

View File

@@ -1,19 +1,18 @@
### Server Variables ###
# Next
NODE_ENV=
SKIP_ENV_VALIDATION=
SITE_URL=
# Convex
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
NEXT_PUBLIC_CONVEX_URL=
SETUP_SCRIPT_RAN=
# Sentry
SENTRY_AUTH_TOKEN=
CI=
### Client Variables ###
# Next
NEXT_PUBLIC_SITE_URL=
# Convex
NEXT_PUBLIC_CONVEX_URL=
# Sentry
# Next # Default Values:
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
# Sentry # Default Values
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL=
NEXT_PUBLIC_SENTRY_ORG=
NEXT_PUBLIC_SENTRY_PROJECT_NAME=

View File

@@ -1,4 +1,4 @@
import { env } from './src/env.js';
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible';
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: env.NEXT_PUBLIC_SENTRY_ORG,
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
authToken: env.SENTRY_AUTH_TOKEN,
org: 'gib',
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !env.CI,
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)

View File

@@ -5,7 +5,6 @@
"type": "module",
"scripts": {
"dev": "next dev --turbo",
"dev:tunnel": "next dev --turbo",
"dev:slow": "next dev",
"build": "next build",
"start": "next start",
@@ -14,9 +13,8 @@
},
"dependencies": {
"@convex-dev/auth": "^0.0.81",
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
@@ -24,41 +22,39 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@sentry/nextjs": "^10.22.0",
"@sentry/nextjs": "^10.11.0",
"@t3-oss/env-nextjs": "^0.13.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.28.0",
"convex": "^1.27.0",
"eslint-plugin-prettier": "^5.5.4",
"input-otp": "^1.4.2",
"lucide-react": "^0.542.0",
"next": "^15.5.6",
"next": "^15.5.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.65.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-image-crop": "^11.0.10",
"require-in-the-middle": "^7.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.46.2",
"typescript-eslint": "^8.43.0",
"vaul": "^1.1.2",
"zod": "^4.1.12"
"zod": "^4.1.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@types/node": "^20.19.23",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^20.19.13",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"dotenv": "^16.6.1",
"eslint-config-next": "^15.5.6",
"eslint-config-next": "^15.5.3",
"npm-run-all": "^4.1.5",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0"
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.3.8"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

View File

@@ -1,9 +1,9 @@
import { env } from './src/env.js';
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import './src/env.js';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
enableLogs: true,
debug: false,
});

View File

@@ -1,14 +0,0 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password',
};
};
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ProfileLayout;

View File

@@ -1,298 +0,0 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSlot,
SubmitButton,
InputOTPSeparator,
} from '@/components/ui';
import { toast } from 'sonner';
import { PASSWORD_MIN, PASSWORD_MAX } from '@/lib/types';
const forgotPasswordSchema = z.object({
email: z.email({ message: 'Invalid email.' }),
});
const resetVerificationSchema = z
.object({
code: z.string({ message: 'Invalid code.' }),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const ForgotPassword = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
const [email, setEmail] = useState<string>('');
const [loading, setLoading] = useState(false);
const [code, setCode] = useState<string>('');
const router = useRouter();
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email },
});
const resetVerificationForm = useForm<
z.infer<typeof resetVerificationSchema>
>({
resolver: zodResolver(resetVerificationSchema),
defaultValues: { code, newPassword: '', confirmPassword: '' },
});
const handleForgotPasswordSubmit = async (
values: z.infer<typeof forgotPasswordSchema>,
) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
const handleResetVerificationSubmit = async (
values: z.infer<typeof resetVerificationSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('newPassword', values.newPassword);
formData.append('email', email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData);
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[400px] w-sm lg:w-md'>
<CardHeader className='flex flex-col gap-4 items-center'>
{flow === 'reset' ? (
<>
<CardTitle className='font-bold text-2xl'>
Forgot Password
</CardTitle>
<CardDescription>
Enter your email address and we will send you a link to reset
your password.
</CardDescription>
</>
) : (
<>
<CardTitle className='font-bold text-2xl'>
Reset Password
</CardTitle>
<CardDescription>
Enter your code and new password and we will reset your
password.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
<Card className='bg-card/50'>
<CardContent>
{flow === 'reset' ? (
<Form {...forgotPasswordForm}>
<form
onSubmit={forgotPasswordForm.handleSubmit(
handleForgotPasswordSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={forgotPasswordForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Sending Email...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Send Email
</SubmitButton>
</form>
</Form>
) : (
<Form {...resetVerificationForm}>
<form
onSubmit={resetVerificationForm.handleSubmit(
handleResetVerificationSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={resetVerificationForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
</FormDescription>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
New Password
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Resetting Password...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Reset Password
</SubmitButton>
</form>
</Form>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
};
export default ForgotPassword;

View File

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

View File

@@ -12,17 +12,11 @@ import {
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
Separator,
SubmitButton,
Tabs,
TabsContent,
@@ -30,10 +24,6 @@ import {
TabsTrigger,
} from '@/components/ui';
import { toast } from 'sonner';
import {
GibsAuthSignInButton,
MicrosoftSignInButton,
} from '@/components/layout/auth/buttons';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
const signInFormSchema = z.object({
@@ -50,7 +40,7 @@ const signUpFormSchema = z
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.email({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z
@@ -85,17 +75,9 @@ const signUpFormSchema = z
path: ['confirmPassword'],
});
const verifyEmailFormSchema = z.object({
code: z.string({ message: 'Invalid code.' }),
});
const SignIn = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
'signIn',
);
const [email, setEmail] = useState<string>('');
const [code, setCode] = useState<string>('');
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
const [loading, setLoading] = useState(false);
const router = useRouter();
@@ -108,17 +90,12 @@ const SignIn = () => {
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email,
email: '',
password: '',
confirmPassword: '',
},
});
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
resolver: zodResolver(verifyEmailFormSchema),
defaultValues: { code },
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
@@ -126,12 +103,13 @@ const SignIn = () => {
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
await signIn('password', formData);
signInForm.reset();
router.push('/');
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
@@ -146,107 +124,17 @@ const SignIn = () => {
try {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('email-verification');
});
await signIn('password', formData);
signUpForm.reset();
router.push('/');
} catch (error) {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
setLoading(false);
}
};
const handleVerifyEmail = async (
values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('flow', flow);
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
if (flow === 'email-verification') {
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
<CardContent>
<div className='text-center mb-6'>
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
<p className='text-muted-foreground'>We sent a code to {email}</p>
</div>
<Form {...verifyEmailForm}>
<form
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
className='flex flex-col space-y-8'
>
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your email.
</FormDescription>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Verify Email
</SubmitButton>
</form>
</Form>
<div className='text-center mt-4'>
<button
onClick={() => setFlow('signUp')}
className='text-sm text-muted-foreground hover:underline'
>
Back to Sign Up
</button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
@@ -323,28 +211,12 @@ const SignIn = () => {
<SubmitButton
disabled={loading}
pendingText='Signing in...'
className='text-xl font-semibold w-2/3 mx-auto'
className='text-lg font-semibold w-2/3 mx-auto'
>
Sign In
</SubmitButton>
</form>
</Form>
<div className='flex justify-center'>
<div
className='flex flex-row items-center
my-2.5 mx-auto justify-center w-1/4'
>
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div>
</div>
<div className='flex justify-center mb-3'>
<MicrosoftSignInButton />
</div>
<div className='flex justify-center mt-3'>
<GibsAuthSignInButton />
</div>
</CardContent>
</Card>
</TabsContent>
@@ -437,25 +309,12 @@ const SignIn = () => {
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-xl font-semibold w-2/3 mx-auto'
className='text-lg font-semibold w-2/3 mx-auto'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='flex my-auto justify-center w-2/3'>
<div className='flex flex-row w-1/3 items-center my-2.5'>
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div>
</div>
<div className='flex justify-center mb-3'>
<MicrosoftSignInButton type='signUp' />
</div>
<div className='flex justify-center mt-3'>
<GibsAuthSignInButton type='signUp' />
</div>
</CardContent>
</Card>
</TabsContent>

View File

@@ -38,36 +38,38 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
Sentry.captureException(error);
}, [error]);
return (
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
<ConvexClientProvider>
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ConvexClientProvider>
<TVModeProvider>
<Header />
<main className='min-h-[90vh] flex flex-col items-center'>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
</main>
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<TVModeProvider>
<Header />
<main className='min-h-[90vh] flex flex-col items-center'>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
</main>
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexClientProvider>
);
};
export default GlobalError;

View File

@@ -4,8 +4,6 @@ import '@/styles/globals.css';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import {
ConvexClientProvider,
LunchReminder,
NotificationsPermission,
ThemeProvider,
TVModeProvider,
} from '@/components/providers';
@@ -24,11 +22,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = generateMetadata();
const RootLayout = async ({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
}>) {
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
@@ -50,8 +48,6 @@ const RootLayout = async ({
<Header />
{children}
<Toaster />
<NotificationsPermission />
<LunchReminder />
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
@@ -60,5 +56,4 @@ const RootLayout = async ({
</PlausibleProvider>
</ConvexAuthNextjsServerProvider>
);
};
export default RootLayout;
}

View File

@@ -1,39 +0,0 @@
import Image from 'next/image';
import { useAuthActions } from '@convex-dev/auth/react';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type Props = {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
};
export const GibsAuthSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('authentik')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='flex flex-row my-auto space-x-1'>
<Image
src={'/icons/misc/gibs-auth-logo.png'}
className=''
alt="Gib's Auth"
width={30}
height={30}
/>
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Gib&apos;s Auth</p>
</div>
</Button>
);
};

View File

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

View File

@@ -1,39 +0,0 @@
import { useAuthActions } from '@convex-dev/auth/react';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type Props = {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
};
export const MicrosoftSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('microsoft-entra-id')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='flex flex-row my-auto space-x-2'>
<div className='flex flex-row my-auto'>
<svg className='scale-150' viewBox='0 0 23 23'>
<path fill='#f35325' d='M1 1h10v10H1z' />
<path fill='#81bc06' d='M12 1h10v10H12z' />
<path fill='#05a6f0' d='M1 12h10v10H1z' />
<path fill='#ffba08' d='M12 12h10v10H12z' />
</svg>
</div>
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Microsoft</p>
</div>
</Button>
);
};

View File

@@ -1,5 +1,6 @@
'use client';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react';
import {
type Preloaded,
@@ -9,14 +10,13 @@ import {
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import {
Avatar,
AvatarImage,
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
ImageCropReset,
Input,
} from '@/components/ui';
import { toast } from 'sonner';
@@ -48,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser);
const updateUserImage = useMutation(api.auth.updateUserImage);
const currentImageUrl = useQuery(
api.files.getImageUrl,
@@ -97,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
storageId: Id<'_storage'>;
};
await updateUser({ image: uploadResponse.storageId });
await updateUserImage({ storageId: uploadResponse.storageId });
toast.success('Profile picture updated.');
handleReset();
@@ -121,7 +121,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold'
className='h-32 w-32'
fallbackProps={{ className: 'text-4xl font-semibold' }}
userIconProps={{ size: 100 }}
/>
<div
@@ -172,6 +173,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
<ImageCropContent className='max-w-sm' />
<div className='flex items-center gap-2'>
<ImageCropApply />
<ImageCropReset />
<Button
onClick={handleReset}
size='icon'
@@ -188,14 +190,19 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
{/* Cropped preview + actions */}
{croppedImage && (
<div className='flex flex-col items-center gap-3'>
<Avatar className='h-42 w-42'>
<AvatarImage alt='Cropped preview' src={croppedImage} />
</Avatar>
<div className='flex items-center gap-1'>
<Image
alt='Cropped preview'
className='overflow-hidden rounded-full'
height={128}
src={croppedImage}
unoptimized
width={128}
/>
<div className='flex items-center gap-2'>
<Button
onClick={handleSave}
disabled={isUploading}
className='px-4'
className='px-6'
>
{isUploading ? (
<span className='inline-flex items-center gap-2'>
@@ -210,10 +217,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={handleReset}
size='icon'
type='button'
className='dark:bg-red-500/30 bg-red-400/80
hover:dark:text-red-300/60 hover:text-red-800/80
hover:dark:bg-accent'
variant='secondary'
variant='ghost'
>
<XIcon className='size-4' />
</Button>

View File

@@ -3,7 +3,6 @@ import { useState } from 'react';
import { useAction } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { z } from 'zod';
import { type Preloaded, usePreloadedQuery } from 'convex/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
@@ -19,7 +18,6 @@ import {
FormLabel,
FormMessage,
Input,
Separator,
SubmitButton,
} from '@/components/ui';
import { toast } from 'sonner';
@@ -64,12 +62,7 @@ const formSchema = z
path: ['confirmPassword'],
});
type ResetFormProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
const user = usePreloadedQuery(preloadedUser);
export const ResetPasswordForm = () => {
const [loading, setLoading] = useState(false);
const changePassword = useAction(api.auth.updateUserPassword);
@@ -101,12 +94,10 @@ export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
setLoading(false);
}
};
return user?.provider !== 'password' ? (
<div />
) : (
return (
<>
<Separator />
<CardHeader>
<CardHeader className='pb-5'>
<CardTitle className='text-2xl'>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure

View File

@@ -1,5 +1,5 @@
'use client';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { z } from 'zod';
@@ -7,9 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
@@ -19,7 +16,6 @@ import {
FormMessage,
Input,
SubmitButton,
Switch,
} from '@/components/ui';
import { toast } from 'sonner';
@@ -36,11 +32,6 @@ const formSchema = z.object({
email: z.email({
message: 'Please enter a valid email address.',
}),
lunchTime: z
.string()
.trim()
.min(3, { message: 'Must be a valid 24-hour time. Example: 13:00' }),
automaticLunch: z.boolean(),
});
type UserInfoFormProps = {
@@ -51,47 +42,28 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
const initialValues = useMemo<z.infer<typeof formSchema>>(
() => ({
name: user?.name ?? '',
email: user?.email ?? '',
lunchTime: user?.lunchTime ?? '12:00',
automaticLunch: user?.automaticLunch ?? false,
}),
[user?.name, user?.email, user?.lunchTime, user?.automaticLunch],
);
const updateUserName = useMutation(api.auth.updateUserName);
const updateUserEmail = useMutation(api.auth.updateUserEmail);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: initialValues,
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = [];
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const lunchTime = values.lunchTime.trim();
const automaticLunch = values.automaticLunch;
const patch: Partial<{
name: string;
email: string;
lunchTime: string;
automaticLunch: boolean;
}> = {};
if (name !== (user?.name ?? '') && name !== undefined)
patch.name = name;
if (email !== (user?.email ?? '') && email !== undefined)
patch.email = email;
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
patch.lunchTime = lunchTime;
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
patch.automaticLunch = automaticLunch;
if (Object.keys(patch).length === 0) return;
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
if (ops.length === 0) return;
setLoading(true);
try {
await updateUser(patch);
form.reset(patch);
await Promise.all(ops);
form.reset({ name, email });
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
@@ -102,107 +74,48 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
};
return (
<>
<CardHeader>
<CardTitle className='text-2xl'>Account Information</CardTitle>
<CardDescription>Update your account information here.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
disabled={user?.provider !== 'password'}
/>
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex flex-row justify-center space-x-10'>
<FormField
control={form.control}
name='lunchTime'
render={({ field }) => (
<FormItem className='sm:w-2/5'>
<div className='flex flex-row space-x-2 my-auto'>
<FormLabel>Lunch Time</FormLabel>
<FormControl>
<Input type='time' className='w-28' {...field} />
</FormControl>
</div>
<FormDescription>Your regular lunch time.</FormDescription>
<FormDescription className='dark:text-red-300/60 text-red-800/80'>
{!user?.lunchTime && 'Not currently set.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='automaticLunch'
render={({ field }) => (
<FormItem className='w-2/5 mt-2'>
<div className='flex flex-row space-x-2 my-auto'>
<FormControl>
<Switch
className='border-solid border-primary'
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Automatic Lunch</FormLabel>
</div>
<FormDescription>
Automatically take your lunch at the time you specify.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-center mt-5'>
<SubmitButton
className='lg:w-1/3 w-2/3 text-[1.0rem]'
disabled={loading}
pendingText='Saving...'
>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
<div className='flex justify-center'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
);
};

View File

@@ -10,11 +10,9 @@ import {
Button,
Card,
CardContent,
Checkbox,
Drawer,
DrawerTrigger,
Input,
Label,
SubmitButton,
Tabs,
TabsContent,
@@ -51,7 +49,6 @@ export const StatusList = ({
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [persistStatus, setPersistStatus] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
const [previousStatuses, setPreviousStatuses] = useState(statuses);
@@ -101,24 +98,11 @@ export const StatusList = ({
throw new Error('Status must be between 3 & 80 characters');
}
if (selectedUserIds.length === 0 && user?.id) {
await bulkCreate({
message,
userIds: [user.id],
persistentStatus: persistStatus
});
await bulkCreate({ message, userIds: [user.id] });
} else {
await bulkCreate({
message,
userIds: selectedUserIds,
persistentStatus: persistStatus
});
await bulkCreate({ message, userIds: selectedUserIds });
}
toast.success('Status updated.');
toast.success('Status updated.', {
duration: 2000,
closeButton: true,
dismissible: true,
});
setSelectedUserIds([]);
setSelectAll(false);
setStatusInput('');
@@ -140,9 +124,9 @@ export const StatusList = ({
const containerCn = ccn({
context: tvMode,
className: 'mx-auto',
className: 'max-w-4xl mx-auto',
on: 'px-6',
off: 'max-w-4xl px-4 sm:px-6',
off: 'px-4 sm:px-6',
});
const tabsCn = ccn({
@@ -165,13 +149,13 @@ export const StatusList = ({
<TabsList className={tabsCn}>
<TabsTrigger value='status' className='py-3 sm:py-8'>
<div className='flex items-center gap-2 sm:gap-3'>
<Activity className='text-primary sm:scale-150' />
<h1 className='text-base sm:text-2xl font-bold'>Status List</h1>
<Activity className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
<h1 className='text-base sm:text-2xl font-bold'>Team Status</h1>
</div>
</TabsTrigger>
<TabsTrigger value='history' className='py-3 sm:py-8'>
<div className='flex items-center gap-2 sm:gap-3'>
<History className='text-primary sm:scale-150' />
<History className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
<h1 className='text-base sm:text-2xl font-bold'>
Status History
</h1>
@@ -201,26 +185,22 @@ export const StatusList = ({
{/* Desktop header */}
<div className={headerCn}>
<div className='flex w-full justify-between px-4'>
<div className='flex items-center gap-4 text-xs sm:text-base'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
<span>{statuses.length} members</span>
</div>
<div className='flex items-center gap-4 text-xs sm:text-base'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
<span>{statuses.length} members</span>
</div>
<div className='flex items-center gap-4 text-xs sm:text-base'>
<div className='flex items-center gap-2 text-xs'>
<Link href='/table' className='font-medium hover:underline'>
Miss the old table?
</Link>
</div>
<div className='flex items-center gap-2 text-xs'>
<Link href='/table' className='font-medium hover:underline'>
Miss the old table?
</Link>
</div>
</div>
</div>
{/* Card list */}
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
{previousStatuses.map((statusData) => {
{statuses.map((statusData) => {
const { user: u, status: s } = statusData;
const isSelected = selectedUserIds.includes(u.id);
const isAnimating = animatingIds.has(u.id);
@@ -232,7 +212,6 @@ export const StatusList = ({
className={`
relative rounded-xl border transition-all
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
${s?.persistentStatus ? 'bg-black/10' : ''}
${
isSelected
? 'border-primary bg-primary/5'
@@ -253,46 +232,43 @@ export const StatusList = ({
<div className='flex items-start gap-3 sm:gap-4'>
{/* Avatar */}
<div className='shrink-0'>
<div className='flex-shrink-0'>
<BasedAvatar
src={u.imageUrl}
fullName={u.name ?? 'User'}
className={`
transition-all duration-300
${tvMode ? 'w-36 h-36 text-4xl' : 'w-10 h-10 sm:w-12 sm:h-12'}
${tvMode ? 'w-18 h-18' : 'w-10 h-10 sm:w-12 sm:h-12'}
${isAnimating ? 'ring-primary/30 ring-4' : ''}
`}
/>
</div>
{/* Content */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
<h3
className={`font-semibold
${tvMode ? 'text-5xl' : 'text-base sm:text-xl'}
className={`
font-semibold truncate
${tvMode ? 'text-3xl' : 'text-base sm:text-xl'}
`}
title={u.name ?? u.email ?? 'User'}
>
{u.name ?? u.email ?? 'User'}
</h3>
{isUpdatedByOther && s?.updatedBy && (
<div
className='hidden sm:flex items-center gap-2
text-muted-foreground min-w-0'
>
<span
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
>
via
</span>
<span className='text-sm'>via</span>
<BasedAvatar
src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name ?? 'User'}
className={`${tvMode ? 'w-14 h-14 text-xl' : 'w-6 h-6'}`}
className='w-4 h-4'
/>
<span
className={`${tvMode ? 'text-4xl' : 'truncate'}`}
>
<span className='text-sm truncate'>
{s.updatedBy.name ??
s.updatedBy.email ??
'another user'}
@@ -300,67 +276,51 @@ export const StatusList = ({
</div>
)}
</div>
<div
className={`
mb-2 sm:mb-3
${tvMode ? 'text-6xl' : 'text-[0.95rem] sm:text-lg'}
${s ? 'text-foreground' : 'text-muted-foreground italic'}
mb-2 sm:mb-3 leading-relaxed break-words
${tvMode ? 'text-2xl' : 'text-[0.95rem] sm:text-lg'}
${
s
? 'text-foreground'
: 'text-muted-foreground italic'
}
line-clamp-2
`}
title={s?.message ?? undefined}
>
{s?.message ?? 'No status yet.'}
</div>
{/* Meta - only show here when NOT in TV mode */}
{!tvMode && (
<div className='flex items-center text-muted-foreground gap-3 sm:gap-4'>
<div className='flex items-center gap-1.5'>
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
<span className='text-sm sm:text-lg'>
{s ? formatTime(s.updatedAt) : '--:--'}
</span>
</div>
<div className='flex items-center gap-1.5'>
<Calendar className='w-4 h-4 sm:w-4 sm:h-4' />
<span className='text-sm sm:text-lg'>
{s ? formatDate(s.updatedAt) : '--/--'}
</span>
</div>
{s && (
<div className='flex items-center gap-1.5'>
<Activity className='w-4 h-4 sm:w-4 sm:h-4' />
<span className='text-sm sm:text-lg'>
{getStatusAge(s.updatedAt)}
</span>
</div>
)}
</div>
)}
</div>
{/* Date/Time Column - only show when in TV mode */}
{tvMode && (
<div className='flex flex-col items-end gap-2 text-muted-foreground min-w-0'>
{/* Meta */}
<div
className='flex items-center gap-3 sm:gap-4
text-muted-foreground'
>
<div className='flex items-center gap-1.5'>
<Clock className='w-8 h-8' />
<span className='text-4xl'>
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
<span className='text-xs sm:text-sm'>
{s ? formatTime(s.updatedAt) : '--:--'}
</span>
</div>
<div className='flex items-center gap-1.5'>
<Calendar className='w-8 h-8' />
<span className='text-4xl'>
<div className='hidden xs:flex items-center gap-1.5'>
<Calendar className='w-4 h-4' />
<span className='text-xs sm:text-sm'>
{s ? formatDate(s.updatedAt) : '--/--'}
</span>
</div>
{s && (
<div className='flex items-center gap-1.5'>
<Activity className='w-8 h-8' />
<span className='text-4xl'>
<Activity className='w-4 h-4' />
<span className='text-xs sm:text-sm'>
{getStatusAge(s.updatedAt)}
</span>
</div>
)}
</div>
)}
</div>
{/* Actions */}
{!tvMode && (
<div className='flex flex-col items-end gap-2'>
@@ -381,6 +341,7 @@ export const StatusList = ({
</div>
)}
</div>
{/* Mobile "via user" line */}
{isUpdatedByOther && s?.updatedBy && (
<div
@@ -414,29 +375,17 @@ export const StatusList = ({
>
<CardContent className='p-6'>
<div className='flex flex-col gap-4'>
<div className='flex gap-3 w-full justify-between'>
<div className='flex gap-3 items-center'>
<Zap className='w-6 h-6 text-primary' />
<h3 className='text-xl font-semibold'>Update Status</h3>
{selectedUserIds.length > 0 && (
<span
className='px-2 py-1 bg-primary/10 text-primary
text-sm rounded-full'
>
{selectedUserIds.length} selected
</span>
)}
</div>
<div className='flex space-x-2 items-center'>
<Checkbox
checked={persistStatus}
className='border border-primary'
onCheckedChange={() => setPersistStatus(!persistStatus)}
/>
<Label>
Persist Status
</Label>
</div>
<div className='flex items-center gap-3'>
<Zap className='w-5 h-5 text-primary' />
<h3 className='text-lg font-semibold'>Update Status</h3>
{selectedUserIds.length > 0 && (
<span
className='px-2 py-1 bg-primary/10 text-primary
text-sm rounded-full'
>
{selectedUserIds.length} selected
</span>
)}
</div>
<div className='flex gap-3'>
<Input
@@ -494,7 +443,7 @@ export const StatusList = ({
<div
className='md:hidden fixed bottom-0 left-0 right-0 z-50
border-t bg-background/95 backdrop-blur
supports-backdrop-filter:bg-background/60 p-3
supports-[backdrop-filter]:bg-background/60 p-3
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
>
<div className='flex items-center justify-between mb-2'>
@@ -507,16 +456,6 @@ export const StatusList = ({
Update your status
</span>
)}
<div className='flex flex-row space-x-2'>
<Checkbox
className='border border-primary'
checked={persistStatus}
onCheckedChange={() => setPersistStatus(!persistStatus)}
/>
<Label className='text-xs'>
Persist Status
</Label>
</div>
<Button variant='outline' size='sm' onClick={handleSelectAll}>
{selectAll ? 'Clear' : 'Select all'}
</Button>

View File

@@ -8,6 +8,8 @@ import { useTVMode } from '@/components/providers';
import {
BasedAvatar,
Button,
Card,
CardContent,
Drawer,
DrawerTrigger,
Input,
@@ -15,7 +17,7 @@ import {
} from '@/components/ui';
import { toast } from 'sonner';
import { ccn, formatTime, formatDate } from '@/lib/utils';
import { Clock, Calendar } from 'lucide-react';
import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
import { StatusHistory } from '@/components/layout/status';
type StatusTableProps = {
@@ -118,7 +120,7 @@ export const StatusTable = ({
</div>
<table className='w-full text-center rounded-md'>
<thead>
<tr className='dark:bg-muted bg-accent/30'>
<tr className='bg-muted'>
{!tvMode && (
<th className={tCheckboxCn}>
<input
@@ -149,11 +151,7 @@ export const StatusTable = ({
<tr
key={u.id}
className={`
${
i % 2 === 0
? 'dark:bg-muted/20 bg-muted'
: 'dark:bg-muted/80 bg-accent/50'
}
${i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
${isSelected ? 'ring-2 ring-primary' : ''}
hover:bg-muted/75 transition-all duration-300
`}
@@ -173,7 +171,7 @@ export const StatusTable = ({
<BasedAvatar
src={u.imageUrl}
fullName={u.name}
className={tvMode ? 'w-16 h-16 text-2xl' : 'w-12 h-12'}
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/>
<div>
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
@@ -182,7 +180,7 @@ export const StatusTable = ({
<BasedAvatar
src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name}
className='w-5 h-5 text-xs'
className='w-5 h-5'
/>
<span className={tvMode ? 'text-xl' : 'text-base'}>
Updated by {s.updatedBy.name}

View File

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

View File

@@ -1,76 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useMutation, useQuery } from 'convex/react';
import { api } from '~/convex/_generated/api';
const nextOccurrenceMs = (hhmm: string, from = new Date()): number => {
const [hStr, mStr] = hhmm.split(':');
const target = new Date(from);
target.setHours(Number(hStr), Number(mStr), 0, 0);
if (target <= from) target.setDate(target.getDate() + 1);
return target.getTime() - from.getTime();
};
export const LunchReminder = () => {
const setStatus = useMutation(api.statuses.createLunchStatus);
const timeoutRef = useRef<number | null>(null);
const user = useQuery(api.auth.getUser, {});
const lunchTime = user?.lunchTime ?? '';
const automaticLunch = user?.automaticLunch ?? false;
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (!lunchTime || automaticLunch) {
return;
}
const schedule = () => {
const ms = nextOccurrenceMs(lunchTime);
console.log('Ms = ', ms);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
void (async () => {
try {
if (
'Notification' in window &&
Notification.permission === 'granted'
) {
new Notification('Lunch time?', {
body: 'Update your status to "At lunch"?',
tag: 'tech-tracker-lunch',
});
}
} catch {}
toast('Lunch time?', {
description: 'Would you like to set your status to "At lunch"?',
action: {
label: 'Set to lunch',
onClick: () => void setStatus({}),
},
cancel: {
label: 'Not now',
onClick: () => console.log('User declined lunch suggestion'),
},
id: 'lunch-reminder',
});
schedule();
})();
}, ms);
};
schedule();
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null
}
};
}, [automaticLunch, lunchTime, setStatus]);
return null;
};

View File

@@ -1,55 +0,0 @@
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
const STORAGE_KEY = 'notif.permission.prompted.v1';
export const NotificationsPermission = () => {
useEffect(() => {
if (typeof window === 'undefined') return;
if (!('Notification' in window)) return;
// Only ask once; tweak logic to your taste.
const prompted = localStorage.getItem(STORAGE_KEY) === '1';
console.log('NotificationsPermission', {
supported: true,
permission: Notification.permission,
prompted,
});
if (prompted) return;
if (Notification.permission === 'default') {
toast('Enable system notifications?', {
id: 'enable-notifications',
description: 'Get a native notification at lunch time (optional).',
action: {
label: 'Enable',
onClick: () => {
// Must be called during the click handler.
const p = Notification.requestPermission();
p.then((perm) => {
localStorage.setItem(STORAGE_KEY, '1');
if (perm === 'granted') {
toast.success('System notifications enabled');
} else {
toast('Okay, we will use in-app toasts instead.');
}
}).catch(() => {
localStorage.setItem(STORAGE_KEY, '1');
toast('Failed to request notification permission.');
});
},
},
cancel: {
label: 'Not now',
onClick: () => {
localStorage.setItem(STORAGE_KEY, '1');
},
},
});
}
}, []);
return null;
};

View File

@@ -1,32 +0,0 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -11,7 +11,6 @@ export {
CardDescription,
CardContent,
} from './card';
export { Checkbox } from './checkbox';
export {
Drawer,
DrawerPortal,
@@ -55,12 +54,6 @@ export {
ImageCropReset,
} from './shadcn-io/image-crop';
export { Input } from './input';
export {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from './input-otp';
export { Label } from './label';
export {
Pagination,
@@ -76,7 +69,6 @@ export { ScrollArea, ScrollBar } from './scroll-area';
export { Separator } from './separator';
export { StatusMessage } from './status-message';
export { SubmitButton } from './submit-button';
export { Switch } from './switch';
export {
Table,
TableHeader,

View File

@@ -1,77 +0,0 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot='input-otp-slot'
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,31 +0,0 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot='switch'
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot='switch-thumb'
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -7,26 +7,20 @@ export const env = createEnv({
.enum(['development', 'test', 'production'])
.default('development'),
SKIP_ENV_VALIDATION: z.boolean().default(false),
SITE_URL: z.url().default('http://localhost:3000'),
SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(true),
},
client: {
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
NEXT_PUBLIC_CONVEX_URL: z
.url()
.default('https://api.dev.convex.gbrown.org'),
NEXT_PUBLIC_SENTRY_DSN: z
.url()
.default('https://96df775337cce23d925616dd5aea8857@sentry.gbrown.org/2'),
NEXT_PUBLIC_SENTRY_URL: z.url().default('https://sentry.gbrown.org'),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_SENTRY_URL: z.url(),
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string().default('techtracker-next'),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SITE_URL: process.env.SITE_URL,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,

View File

@@ -3,21 +3,14 @@ import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1,
enableLogs: true,
tracesSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
debug: false,
});
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View File

@@ -1,7 +1,10 @@
import * as Sentry from '@sentry/nextjs';
import type { Instrumentation } from 'next';
export const register = async () => await import('../sentry.server.config');
export const register = async () => {
await import('../sentry.server.config');
};
export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args);
};

View File

@@ -51,8 +51,7 @@ export const formatDate = (timestamp: Timestamp, locale = 'en-US'): string => {
const date = toDate(timestamp);
if (!date) return '--/--';
return date.toLocaleDateString(locale, {
weekday: 'long',
month: 'short',
month: 'long',
day: 'numeric',
});
};

View File

@@ -8,19 +8,16 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
const isSignInPage = createRouteMatcher(['/signin']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/signin');
}
},
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
);
export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/signin');
}
});
export const config = {
// The following matcher runs middleware on all routes

1408
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,55 @@
# syntax=docker/dockerfile:1
# --- Bun on Alpine for build ---
FROM oven/bun:alpine AS base
# --- deps: install node_modules with Bun ---
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY apps/next/package.json apps/next/bun.lockb* ./
RUN bun install --frozen-lockfile
# Copy package + whichever Bun lock file you have (optional)
COPY package.json bun.lockb* bun.lock* ./
COPY tsconfig.base.json ./
COPY apps/next/package.json ./apps/next/
# If bun.lockb exists, enforce frozen; otherwise install and generate it
RUN if [ -f bun.lockb ]; then \
bun install --frozen-lockfile; \
else \
bun install; \
fi
# --- builder: build Next.js with Bun ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.base.json /tsconfig.base.json
COPY packages/backend ./packages/backend
COPY apps/next ./
COPY --from=deps /app/tsconfig.base.json ./tsconfig.base.json
COPY apps/next ./apps/next
COPY packages ./packages
WORKDIR /app/apps/next
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build
#FROM base AS runner
# --- runner: Node on Alpine to run server.js ---
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# non-root user
RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001
#RUN adduser --system --uid 1001 nextjs
#RUN chown nextjs:bun .next
COPY --from=builder /app/apps/next/public ./public
RUN mkdir .next && chown -R nextjs:nodejs .next
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next
# Copy from the correct paths (builder has apps/next at root)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next standalone output
COPY --from=builder /app/apps/next/.next/standalone ./
COPY --from=builder /app/apps/next/.next/static ./.next/static
COPY --from=builder /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

View File

@@ -7,18 +7,17 @@ services:
build:
context: ../
dockerfile: ./docker/Dockerfile
image: ${NEXT_CONTAINER_NAME}:alpine
container_name: ${NEXT_CONTAINER_NAME}
image: ${CONTAINER_NAME}:alpine
container_name: ${CONTAINER_NAME}
env_file: [.env]
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN_NAME}
hostname: ${CONTAINER_NAME}
domainname: ${DOMAIN_NAME}
networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:3000']
#ports: ['${PORT}:3000']
depends_on: ['convex-backend']
tty: true
stdin_open: true
restart: unless-stopped
convex-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-00bd92723422f3bff968230c94ccdeb8c1719832}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}

View File

@@ -0,0 +1 @@
Convex.gib

View File

@@ -0,0 +1 @@
d35a9c02aeef0070fac3069d51ac4926744b251d0e6d97dc239d78bce67959b6

BIN
docker/data/db.sqlite3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

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