Compare commits
12 Commits
d2eea9880a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bc04dbf6b | |||
| 6e78140103 | |||
| 8c62780dcb | |||
| 2d0a34347b | |||
| 5e37d10300 | |||
| 1e61e34fb8 | |||
| 07dc8d7976 | |||
| ee99ab11c9 | |||
| 0ecf6238de | |||
| 60dc57ddf7 | |||
| a8bb610be7 | |||
| 81e6a5aaa6 |
49
.dockerignore
Normal file
49
.dockerignore
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui';
|
||||
|
||||
const features = [
|
||||
{
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,8 +18,9 @@ out
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env.local
|
||||
.env*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
2
packages/ui/src/hooks/index.tsx
Normal file
2
packages/ui/src/hooks/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useIsMobile } from './use-mobile';
|
||||
export { useOnClickOutside } from './use-on-click-outside';
|
||||
60
packages/ui/src/hooks/use-on-click-outside.tsx
Normal file
60
packages/ui/src/hooks/use-on-click-outside.tsx
Normal 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 };
|
||||
@@ -381,4 +381,4 @@ export {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from './tooltip';
|
||||
export { useIsMobile } from './hooks/use-mobile';
|
||||
export { useIsMobile, useOnClickOutside } from './hooks';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user