Compare commits

...

12 Commits

26 changed files with 200 additions and 197 deletions

49
.dockerignore Normal file
View File

@@ -0,0 +1,49 @@
# Dependencies - MUST exclude these
node_modules
**/node_modules
.pnp
.pnp.js
# Turbo
.turbo
**/.turbo
# Next.js build artifacts
.next
**/.next
out
**/out
# Development
.git
.gitignore
*.log
#.env
#.env.*
!.env.example
.vscode
.idea
# Tests
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
# Build artifacts
dist
**/dist
build
**/build
# Convex local
packages/backend/.convex
# OS
.DS_Store
Thumbs.db
# Docker
docker
Dockerfile
.dockerignore

View File

@@ -1,11 +1,13 @@
import { createJiti } from 'jiti';
import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible';
import { env } from './src/env.js';
const jiti = createJiti(import.meta.url);
await jiti.import('./src/env');
/** @type {import("next").NextConfig} */
const config = withPlausibleProxy({
customDomain: env.NEXT_PUBLIC_PLAUSIBLE_URL,
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
})({
output: 'standalone',
images: {
@@ -30,12 +32,12 @@ const config = 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: process.env.NEXT_PUBLIC_SENTRY_ORG,
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,6 +5,7 @@
"private": true,
"scripts": {
"build": "bun with-env next build",
"build:env": "bun with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "bun with-env next dev --turbo",
"dev:tunnel": "bun with-env next dev --turbo",

View File

@@ -252,24 +252,27 @@ const SignIn = () => {
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='items-center'
className='items-center flex-col'
>
<TabsList className='py-6'>
<TabsList>
<TabsTrigger
value='signIn'
className='cursor-pointer p-6 text-2xl font-bold'
className='cursor-pointer py-2 px-6 text-2xl font-bold'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='cursor-pointer p-6 text-2xl font-bold'
className='cursor-pointer py-2 px-6 text-2xl font-bold'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent value='signIn'>
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
<TabsContent
value='signIn'
className='min-h-[560px] items-center flex flex-row'
>
<Card className='bg-card/50 min-w-xs sm:min-w-sm py-10'>
<CardContent>
<Form {...signInForm}>
<form

View File

@@ -15,6 +15,7 @@ import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
import { Button, ThemeProvider, Toaster } from '@gib/ui';
import { env } from '@/env';
export const metadata: Metadata = generateMetadata();
@@ -45,8 +46,8 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
}, [error]);
return (
<PlausibleProvider
domain='convexmonorepo.gbrown.org'
customDomain='https://plausible.gbrown.org'
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body

View File

@@ -39,10 +39,10 @@ const RootLayout = ({
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL}
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en'>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -1,13 +1,8 @@
import Image from 'next/image';
import Link from 'next/link';
import { Button } from '@gib/ui/button';
export function CTA() {
return (
<section className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-4xl'>
<div className='border-border/40 from-muted/50 to-muted/30 rounded-2xl border bg-gradient-to-br p-8 text-center md:p-12'>
<div className='border-border/40 from-muted/50 to-muted/30 rounded-2xl border bg-linear-to-br p-8 text-center md:p-12'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
Ready to Build Something Amazing?
</h2>

View File

@@ -1,4 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui';
const features = [
{

View File

@@ -2,7 +2,7 @@ import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Button } from '@gib/ui/button';
import { Button } from '@gib/ui';
const kanitSans = Kanit({
subsets: ['latin'],

View File

@@ -1,17 +1,7 @@
'use client';
import type { Preloaded } from 'convex/react';
import { usePreloadedQuery } from 'convex/react';
import type { api } from '@gib/backend/convex/_generated/api.js';
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
interface ProfileCardProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
const user = usePreloadedQuery(preloadedUser);
const ProfileHeader = () => {
return (
<CardHeader>
<CardTitle className='text-xl'>Account Settings</CardTitle>

View File

@@ -51,6 +51,10 @@ export const UserInfoForm = ({
}: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const userProvider = usePreloadedQuery(preloadedProvider);
const providerMap: Record<string, string> = {
unknown: 'Provider',
authentik: 'Gib\'s Auth',
};
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
@@ -137,16 +141,16 @@ export const UserInfoForm = ({
{...field}
type='email'
placeholder='john@example.com'
disabled={userProvider !== 'email'}
disabled={userProvider !== 'password'}
/>
</FormControl>
{userProvider === 'email' ? (
{userProvider === 'password' ? (
<FormDescription>
Your email address for account notifications
</FormDescription>
) : (
<FormDescription>
Email is managed through your {userProvider} account
Email is managed through your {providerMap[userProvider ?? 'unknown']} account
</FormDescription>
)}
<FormMessage />

View File

@@ -28,7 +28,7 @@ export default function Header(headerProps: ComponentProps<'header'>) {
alt='Convex Monorepo'
width={50}
height={50}
className='invert dark:invert-0'
className='invert dark:invert-0 w-15'
/>
<span
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}

View File

@@ -2,17 +2,13 @@ import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod/v4';
export const env = createEnv({
shared: {
server: {
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
SKIP_ENV_VALIDATION: z.boolean().default(false),
SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(false),
},
/**
@@ -31,19 +27,19 @@ export const env = createEnv({
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
experimental__runtimeEnv: {
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
SITE_URL: process.env.SITE_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
NEXT_PUBLIC_SENTRY_PROJECT_NAME: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === 'lint',
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});

View File

@@ -1,7 +1,6 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import { env } from './env.js';
import { env } from '@/env';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
@@ -20,7 +19,7 @@ Sentry.init({
tracesSampleRate: 1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
debug: false,
});

View File

@@ -1,6 +1,5 @@
import * as Sentry from '@sentry/nextjs';
import { env } from './env.js';
import { env } from '@/env';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,

View File

@@ -18,8 +18,9 @@ out
.git
.gitignore
*.log
.env.local
.env*.local
.env
.env.*
!.env.example
.vscode
.idea

View File

@@ -1,13 +1,9 @@
# Next Envrionment Variables
NODE_ENV=production
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=next-app
NEXT_DOMAIN_NAME=gbrown.org
# Port is disabled by default as suggested
# config is to have reverse proxy on the same
# network so you can just forward to the
# port on the internal network.
# NEXT_PORT=3000
NEXT_PORT=3000
NODE_ENV=production
SENTRY_AUTH_TOKEN=
NEXT_PUBLIC_SITE_URL=https://gbrown.org
NEXT_PUBLIC_CONVEX_URL=https://api.convex.gbrown.org

View File

@@ -9,8 +9,8 @@ services:
dockerfile: ./docker/Dockerfile
image: ${NEXT_CONTAINER_NAME}:alpine
container_name: ${NEXT_CONTAINER_NAME}
env_file: [.env]
environment:
- NODE_ENV
- SENTRY_AUTH_TOKEN
- NEXT_PUBLIC_SITE_URL
- NEXT_PUBLIC_CONVEX_URL
@@ -38,7 +38,6 @@ services:
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data]
labels: ['com.centurylinklabs.watchtower.enable=true']
env_file: ['.env']
environment:
- INSTANCE_NAME
- INSTANCE_SECRET
@@ -67,7 +66,6 @@ services:
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
env_file: [.env]
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
depends_on:

View File

@@ -54,16 +54,6 @@ export const getUser = query({
},
});
export const getAllUsers = query(async (ctx) => {
const users = await ctx.db.query('users').collect();
return users ?? null;
});
export const getAllUserIds = query(async (ctx) => {
const users = await ctx.db.query('users').collect();
return users.map((u) => u._id);
});
export const updateUser = mutation({
args: {
name: v.optional(v.string()),

View File

@@ -8,7 +8,7 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
id: 'usesend',
type: 'email',
name: 'UseSend',
from: 'Study Buddy <admin@techtracker.gbrown.org>',
from: 'Convex Monorepo <admin@convexmonorepo.gbrown.org>',
maxAge: 24 * 60 * 60, // 24 hours
async generateVerificationToken() {
@@ -23,7 +23,7 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
async sendVerificationRequest(params) {
const { identifier: to, provider, url, theme, token } = params;
//const { host } = new URL(url);
const host = 'TechTracker';
const host = 'Convex Monorepo';
const useSend = new UseSend(
process.env.USESEND_API_KEY!,

View File

@@ -3,22 +3,12 @@ import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
const applicationTables = {
// Users contains name image & email.
// If you would like to save any other information,
// I would recommend including this profiles table
// where you can include settings & anything else you would like tied to the user.
profiles: defineTable({
userId: v.id('users'),
theme_preference: v.optional(v.string()),
}).index('userId', ['userId']),
};
export default defineSchema({
...authTables,
// Default table for users directly from authTable.
// You can extend it if you would like, but it may
// be better to just use the profiles table example
// below.
/*
* Below is the users table definition from authTables
* You can add additional fields here. You can also remove
* the users table here & create a 'profiles' table if you
* prefer to keep auth data separate from application data.
*/
users: defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
@@ -27,9 +17,20 @@ export default defineSchema({
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()),
/* Fields below here are custom & not defined in authTables */
themePreference: v.optional(v.union(
v.literal('light'),
v.literal('dark'),
v.literal('system'),
)),
})
.index('email', ['email'])
.index('name', ['name'])
.index('phone', ['phone']),
.index('phone', ['phone'])
/* Indexes below here are custom & not defined in authTables */
.index('name', ['name']),
};
export default defineSchema({
...authTables,
...applicationTables,
});

View File

@@ -4,68 +4,8 @@
"type": "module",
"exports": {
".": "./src/index.tsx",
"./accordion": "./src/accordion.tsx",
"./alert": "./src/alert.tsx",
"./alert-dialog": "./src/alert-dialog.tsx",
"./aspect-ratio": "./src/aspect-ratio.tsx",
"./avatar": "./src/avatar.tsx",
"./badge": "./src/badge.tsx",
"./based-avatar": "./src/based-avatar.tsx",
"./based-progress": "./src/based-progress.tsx",
"./breadcrumb": "./src/breadcrumb.tsx",
"./button": "./src/button.tsx",
"./button-group": "./src/button-group.tsx",
"./calendar": "./src/calendar.tsx",
"./card": "./src/card.tsx",
"./carousel": "./src/carousel.tsx",
"./chart": "./src/chart.tsx",
"./checkbox": "./src/checkbox.tsx",
"./collapsible": "./src/collapsible.tsx",
"./combobox": "./src/combobox.tsx",
"./command": "./src/command.tsx",
"./context-menu": "./src/context-menu.tsx",
"./dialog": "./src/dialog.tsx",
"./drawer": "./src/drawer.tsx",
"./dropdown-menu": "./src/dropdown-menu.tsx",
"./empty": "./src/empty.tsx",
"./field": "./src/field.tsx",
"./form": "./src/form.tsx",
"./hover-card": "./src/hover-card.tsx",
"./image-crop": "./src/image-crop.tsx",
"./input": "./src/input.tsx",
"./input-group": "./src/input-group.tsx",
"./input-otp": "./src/input-otp.tsx",
"./item": "./src/item.tsx",
"./kbd": "./src/kbd.tsx",
"./label": "./src/label.tsx",
"./menubar": "./src/menubar.tsx",
"./native-select": "./src/native-select.tsx",
"./navigation-menu": "./src/navigation-menu.tsx",
"./pagination": "./src/pagination.tsx",
"./popover": "./src/popover.tsx",
"./progress": "./src/progress.tsx",
"./radio-group": "./src/radio-group.tsx",
"./resizeable": "./src/resizeable.tsx",
"./scroll-area": "./src/scroll-area.tsx",
"./select": "./src/select.tsx",
"./separator": "./src/separator.tsx",
"./sheet": "./src/sheet.tsx",
"./sidebar": "./src/sidebar.tsx",
"./skeleton": "./src/skeleton.tsx",
"./slider": "./src/slider.tsx",
"./sonner": "./src/sonner.tsx",
"./spinner": "./src/spinner.tsx",
"./status-message": "./src/status-message.tsx",
"./submit-button": "./src/submit-button.tsx",
"./switch": "./src/switch.tsx",
"./table": "./src/table.tsx",
"./tabs": "./src/tabs.tsx",
"./textarea": "./src/textarea.tsx",
"./theme": "./src/theme.tsx",
"./toast": "./src/toast.tsx",
"./toggle": "./src/toggle.tsx",
"./toggle-group": "./src/toggle-group.tsx",
"./tooltip": "./src/tooltip.tsx"
"./hooks": "./src/index.tsx",
"./hooks/*": "./src/hooks/*"
},
"license": "MIT",
"scripts": {

View File

@@ -0,0 +1,2 @@
export { useIsMobile } from './use-mobile';
export { useOnClickOutside } from './use-on-click-outside';

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { MousePointerClick, X } from 'lucide-react';
type EventType =
| 'mousedown'
| 'mouseup'
| 'touchstart'
| 'touchend'
| 'focusin'
| 'focusout';
export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
ref: React.RefObject<T | null> | React.RefObject<T | null>[],
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
eventType: EventType = 'mousedown',
eventListenerOptions: AddEventListenerOptions = {},
): void {
const savedHandler = React.useRef(handler);
React.useLayoutEffect(() => {
savedHandler.current = handler;
}, [handler]);
React.useEffect(() => {
const listener = (event: MouseEvent | TouchEvent | FocusEvent) => {
const target = event.target as Node;
// Do nothing if the target is not connected element with document
if (!target.isConnected) {
return;
}
const isOutside = Array.isArray(ref)
? ref
.filter((r) => Boolean(r.current))
.every((r) => r.current && !r.current.contains(target))
: ref.current && !ref.current.contains(target);
if (isOutside) {
savedHandler.current(event);
}
};
document.addEventListener(
eventType,
listener as EventListener,
eventListenerOptions,
);
return () => {
document.removeEventListener(
eventType,
listener as EventListener,
eventListenerOptions,
);
};
}, [ref, eventType, eventListenerOptions]);
}
export type { EventType };

View File

@@ -381,4 +381,4 @@ export {
TooltipContent,
TooltipProvider,
} from './tooltip';
export { useIsMobile } from './hooks/use-mobile';
export { useIsMobile, useOnClickOutside } from './hooks';

View File

@@ -1,24 +1,16 @@
'use client';
import type { ComponentProps } from 'react';
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
import { ThemeProvider as NextThemesProvider, useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { Button, cn } from '@gib/ui';
const ThemeProvider = ({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
};
interface ThemeToggleProps {
@@ -28,41 +20,25 @@ interface ThemeToggleProps {
const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button {...buttonProps}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
}
const toggleTheme = () => {
if (resolvedTheme === 'dark') setTheme('light');
else setTheme('dark');
};
return (
<Button
variant='outline'
size='icon'
variant="outline"
size="icon"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
{...buttonProps}
onClick={toggleTheme}
className={cn('cursor-pointer', buttonProps?.className)}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90'
className='scale-100 rotate-0 transition-all
dark:scale-0 dark:-rotate-90'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0'
className='absolute scale-0 rotate-90 transition-all
dark:scale-100 dark:rotate-0'
/>
<span className="sr-only">Toggle theme</span>
</Button>
);
};