From 674b2244671b6a9ad2b22d419349617ea99945cb Mon Sep 17 00:00:00 2001 From: gib Date: Sun, 11 Jan 2026 13:56:30 -0500 Subject: [PATCH] I think monorepo is fairly correct now but I don't know for sure --- .../convex/{auth.config.js => auth.config.ts} | 2 +- packages/backend/convex/auth.ts | 114 +++++++++ packages/backend/convex/custom/auth/index.ts | 2 + .../convex/custom/auth/providers/password.ts | 33 +++ .../convex/custom/auth/providers/usesend.ts | 90 +++++++ packages/backend/convex/http.ts | 9 + packages/backend/convex/notes.ts | 71 ------ packages/backend/convex/openai.ts | 76 ------ packages/backend/convex/schema.ts | 37 ++- packages/ui/package.json | 2 +- packages/ui/src/avatar.tsx | 4 +- packages/ui/src/based-avatar.tsx | 7 +- packages/ui/src/based-progress.tsx | 2 +- packages/ui/src/button.tsx | 2 +- packages/ui/src/card.tsx | 2 +- packages/ui/src/checkbox.tsx | 2 +- packages/ui/src/drawer.tsx | 2 +- packages/ui/src/dropdown-menu.tsx | 2 +- packages/ui/src/field.tsx | 4 +- packages/ui/src/form.tsx | 5 +- packages/ui/src/index.ts | 16 -- packages/ui/src/index.tsx | 109 +++++++++ packages/ui/src/input-otp.tsx | 2 +- packages/ui/src/input.tsx | 2 +- packages/ui/src/label.tsx | 2 +- packages/ui/src/pagination.tsx | 6 +- packages/ui/src/progress.tsx | 4 +- packages/ui/src/scroll-area.tsx | 4 +- packages/ui/src/separator.tsx | 2 +- .../ui/src/shadcn-io/image-crop/index.tsx | 7 +- packages/ui/src/sonner.tsx | 3 +- packages/ui/src/status-message.tsx | 6 +- packages/ui/src/submit-button.tsx | 5 +- packages/ui/src/switch.tsx | 4 +- packages/ui/src/table.tsx | 4 +- packages/ui/src/tabs.tsx | 4 +- packages/ui/src/theme.tsx | 222 +++++------------- packages/ui/src/toast.tsx | 27 --- 38 files changed, 485 insertions(+), 412 deletions(-) rename packages/backend/convex/{auth.config.js => auth.config.ts} (65%) create mode 100644 packages/backend/convex/auth.ts create mode 100644 packages/backend/convex/custom/auth/index.ts create mode 100644 packages/backend/convex/custom/auth/providers/password.ts create mode 100644 packages/backend/convex/custom/auth/providers/usesend.ts create mode 100644 packages/backend/convex/http.ts delete mode 100644 packages/backend/convex/notes.ts delete mode 100644 packages/backend/convex/openai.ts delete mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/index.tsx delete mode 100644 packages/ui/src/toast.tsx diff --git a/packages/backend/convex/auth.config.js b/packages/backend/convex/auth.config.ts similarity index 65% rename from packages/backend/convex/auth.config.js rename to packages/backend/convex/auth.config.ts index 16927b1..40b63c7 100644 --- a/packages/backend/convex/auth.config.js +++ b/packages/backend/convex/auth.config.ts @@ -1,7 +1,7 @@ export default { providers: [ { - domain: process.env.CLERK_ISSUER_URL, + domain: process.env.CONVEX_SITE_URL, applicationID: 'convex', }, ], diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts new file mode 100644 index 0000000..788905d --- /dev/null +++ b/packages/backend/convex/auth.ts @@ -0,0 +1,114 @@ +import Authentik from '@auth/core/providers/authentik'; +import { + convexAuth, + getAuthUserId, + modifyAccountCredentials, + retrieveAccount, +} from '@convex-dev/auth/server'; +import { ConvexError, v } from 'convex/values'; + +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { api } from './_generated/api'; +import { action, mutation, query } from './_generated/server'; +import { Password, validatePassword } from './custom/auth'; + +export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ + providers: [Authentik({ allowDangerousEmailAccountLinking: true }), Password], +}); + +const getUserById = async ( + ctx: QueryCtx, + userId: Id<'users'>, +): Promise> => { + const user = await ctx.db.get(userId); + if (!user) throw new ConvexError('User not found.'); + return user; +}; +const isSignedIn = async (ctx: QueryCtx): Promise | null> => { + const userId = await getAuthUserId(ctx); + if (!userId) return null; + const user = await ctx.db.get(userId); + if (!user) return null; + return user; +}; + +export const getUser = query({ + args: { userId: v.optional(v.id('users')) }, + handler: async (ctx, args) => { + const user = await isSignedIn(ctx); + const userId = args.userId ?? user?._id; + if (!userId) throw new ConvexError('Not authenticated or no ID provided.'); + return getUserById(ctx, userId); + }, +}); + +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()), + email: v.optional(v.string()), + image: v.optional(v.id('_storage')), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new ConvexError('Not authenticated.'); + const user = await ctx.db.get(userId); + if (!user) throw new ConvexError('User not found.'); + const patch: Partial<{ + name: string; + email: string; + image: Id<'_storage'>; + }> = {}; + if (args.name !== undefined) patch.name = args.name; + if (args.email !== undefined) patch.email = args.email; + if (args.image !== undefined) { + const oldImage = user.image as Id<'_storage'> | undefined; + patch.image = args.image; + if (oldImage && oldImage !== args.image) { + await ctx.storage.delete(oldImage); + } + } + if (Object.keys(patch).length > 0) { + await ctx.db.patch(userId, patch); + } + return { success: true }; + }, +}); + +export const updateUserPassword = action({ + args: { + currentPassword: v.string(), + newPassword: v.string(), + }, + handler: async (ctx, { currentPassword, newPassword }) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new ConvexError('Not authenticated.'); + const user = await ctx.runQuery(api.auth.getUser, { userId }); + if (!user?.email) throw new ConvexError('User not found.'); + const verified = await retrieveAccount(ctx, { + provider: 'password', + account: { id: user.email, secret: currentPassword }, + }); + if (!verified) throw new ConvexError('Current password is incorrect.'); + + if (!validatePassword(newPassword)) + throw new ConvexError('Invalid password.'); + + await modifyAccountCredentials(ctx, { + provider: 'password', + account: { id: user.email, secret: newPassword }, + }); + + return { success: true }; + }, +}); diff --git a/packages/backend/convex/custom/auth/index.ts b/packages/backend/convex/custom/auth/index.ts new file mode 100644 index 0000000..107d311 --- /dev/null +++ b/packages/backend/convex/custom/auth/index.ts @@ -0,0 +1,2 @@ +export { Password, validatePassword } from './providers/password'; +export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend'; diff --git a/packages/backend/convex/custom/auth/providers/password.ts b/packages/backend/convex/custom/auth/providers/password.ts new file mode 100644 index 0000000..5123041 --- /dev/null +++ b/packages/backend/convex/custom/auth/providers/password.ts @@ -0,0 +1,33 @@ +import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password'; +import { ConvexError } from 'convex/values'; +import { UseSendOTP, UseSendOTPPasswordReset } from '..'; +import { DataModel } from '../../../_generated/dataModel'; + +export const Password = DefaultPassword({ + profile(params, ctx) { + return { + email: params.email as string, + name: params.name as string, + }; + }, + validatePasswordRequirements: (password: string) => { + if (!validatePassword(password)) { + throw new ConvexError('Invalid password.'); + } + }, + reset: UseSendOTPPasswordReset, + verify: UseSendOTP, +}); + +export const validatePassword = (password: string): boolean => { + if ( + password.length < 8 || + password.length > 100 || + !/\d/.test(password) || + !/[a-z]/.test(password) || + !/[A-Z]/.test(password) + ) { + return false; + } + return true; +}; diff --git a/packages/backend/convex/custom/auth/providers/usesend.ts b/packages/backend/convex/custom/auth/providers/usesend.ts new file mode 100644 index 0000000..c196598 --- /dev/null +++ b/packages/backend/convex/custom/auth/providers/usesend.ts @@ -0,0 +1,90 @@ +import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email'; +import { generateRandomString, RandomReader } from '@oslojs/crypto/random'; +import { alphabet } from 'oslo/crypto'; +import { UseSend } from 'usesend-js'; + +export default function UseSendProvider(config: EmailUserConfig): EmailConfig { + return { + id: 'usesend', + type: 'email', + name: 'UseSend', + from: 'TechTracker ', + maxAge: 24 * 60 * 60, // 24 hours + + async generateVerificationToken() { + const random: RandomReader = { + read(bytes) { + crypto.getRandomValues(bytes); + }, + }; + return generateRandomString(random, alphabet('0-9'), 6); + }, + + async sendVerificationRequest(params) { + const { identifier: to, provider, url, theme, token } = params; + //const { host } = new URL(url); + const host = 'TechTracker'; + + const useSend = new UseSend( + process.env.USESEND_API_KEY!, + 'https://usesend.gbrown.org', + ); + + // For password reset, we want to send the code, not the magic link + const isPasswordReset = + url.includes('reset') || provider.id?.includes('reset'); + + const result = await useSend.emails.send({ + from: provider.from!, + to: [to], + subject: isPasswordReset + ? `Reset your password - ${host}` + : `Sign in to ${host}`, + text: isPasswordReset + ? `Your password reset code is ${token}` + : `Your sign in code is ${token}`, + html: isPasswordReset + ? ` +
+

Password Reset Request

+

You requested a password reset. Your reset code is:

+
+ ${token} +
+

This code expires in 1 hour.

+

If you didn't request this, please ignore this email.

+
+ ` + : ` +
+

Your Sign In Code

+

Your verification code is:

+
+ ${token} +
+

This code expires in 24 hours.

+
+ `, + }); + + if (result.error) { + throw new Error('UseSend error: ' + JSON.stringify(result.error)); + } + }, + + options: config, + }; +} + +// Create specific instances for password reset and email verification +export const UseSendOTPPasswordReset = UseSendProvider({ + id: 'usesend-otp-password-reset', + apiKey: process.env.USESEND_API_KEY, + maxAge: 60 * 60, // 1 hour +}); + +export const UseSendOTP = UseSendProvider({ + id: 'usesend-otp', + apiKey: process.env.USESEND_API_KEY, + maxAge: 60 * 20, // 20 minutes +}); diff --git a/packages/backend/convex/http.ts b/packages/backend/convex/http.ts new file mode 100644 index 0000000..3aa3437 --- /dev/null +++ b/packages/backend/convex/http.ts @@ -0,0 +1,9 @@ +import { httpRouter } from 'convex/server'; + +import { auth } from './auth'; + +const http = httpRouter(); + +auth.addHttpRoutes(http); + +export default http; diff --git a/packages/backend/convex/notes.ts b/packages/backend/convex/notes.ts deleted file mode 100644 index 0b53e2d..0000000 --- a/packages/backend/convex/notes.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Auth } from 'convex/server'; -import { v } from 'convex/values'; - -import { internal } from '../convex/_generated/api'; -import { mutation, query } from './_generated/server'; - -export const getUserId = async (ctx: { auth: Auth }) => { - return (await ctx.auth.getUserIdentity())?.subject; -}; - -// Get all notes for a specific user -export const getNotes = query({ - args: {}, - handler: async (ctx) => { - const userId = await getUserId(ctx); - if (!userId) return null; - - const notes = await ctx.db - .query('notes') - .filter((q) => q.eq(q.field('userId'), userId)) - .collect(); - - return notes; - }, -}); - -// Get note for a specific note -export const getNote = query({ - args: { - id: v.optional(v.id('notes')), - }, - handler: async (ctx, args) => { - const { id } = args; - if (!id) return null; - const note = await ctx.db.get(id); - return note; - }, -}); - -// Create a new note for a user -export const createNote = mutation({ - args: { - title: v.string(), - content: v.string(), - isSummary: v.boolean(), - }, - handler: async (ctx, { title, content, isSummary }) => { - const userId = await getUserId(ctx); - if (!userId) throw new Error('User not found'); - const noteId = await ctx.db.insert('notes', { userId, title, content }); - - if (isSummary) { - await ctx.scheduler.runAfter(0, internal.openai.summary, { - id: noteId, - title, - content, - }); - } - - return noteId; - }, -}); - -export const deleteNote = mutation({ - args: { - noteId: v.id('notes'), - }, - handler: async (ctx, args) => { - await ctx.db.delete(args.noteId); - }, -}); diff --git a/packages/backend/convex/openai.ts b/packages/backend/convex/openai.ts deleted file mode 100644 index 8030e5a..0000000 --- a/packages/backend/convex/openai.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { v } from 'convex/values'; -import OpenAI from 'openai'; - -import { internal } from './_generated/api'; -import { internalAction, internalMutation, query } from './_generated/server'; -import { missingEnvVariableUrl } from './utils'; - -export const openaiKeySet = query({ - args: {}, - handler: async () => { - return !!process.env.OPENAI_API_KEY; - }, -}); - -export const summary = internalAction({ - args: { - id: v.id('notes'), - title: v.string(), - content: v.string(), - }, - handler: async (ctx, { id, title, content }) => { - const prompt = `Take in the following note and return a summary: Title: ${title}, Note content: ${content}`; - - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - const error = missingEnvVariableUrl( - 'OPENAI_API_KEY', - 'https://platform.openai.com/account/api-keys', - ); - console.error(error); - await ctx.runMutation(internal.openai.saveSummary, { - id: id, - summary: error, - }); - return; - } - const openai = new OpenAI({ apiKey }); - const output = await openai.chat.completions.create({ - messages: [ - { - role: 'system', - content: - 'You are a helpful assistant designed to output JSON in this format: {summary: string}', - }, - { role: 'user', content: prompt }, - ], - model: 'gpt-4-1106-preview', - response_format: { type: 'json_object' }, - }); - - // Pull the message content out of the response - const messageContent = output.choices[0]?.message.content; - - console.log({ messageContent }); - - const parsedOutput = JSON.parse(messageContent!); - console.log({ parsedOutput }); - - await ctx.runMutation(internal.openai.saveSummary, { - id: id, - summary: parsedOutput.summary, - }); - }, -}); - -export const saveSummary = internalMutation({ - args: { - id: v.id('notes'), - summary: v.string(), - }, - handler: async (ctx, { id, summary }) => { - await ctx.db.patch(id, { - summary: summary, - }); - }, -}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 271606a..44cf964 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1,11 +1,36 @@ import { defineSchema, defineTable } from 'convex/server'; import { v } from 'convex/values'; +import { authTables } from '@convex-dev/auth/server'; + +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({ - notes: defineTable({ - userId: v.string(), - title: v.string(), - content: v.string(), - summary: v.optional(v.string()), - }), + ...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. + users: defineTable({ + name: v.optional(v.string()), + image: v.optional(v.string()), + email: v.optional(v.string()), + emailVerificationTime: v.optional(v.number()), + phone: v.optional(v.string()), + phoneVerificationTime: v.optional(v.number()), + isAnonymous: v.optional(v.boolean()), + }) + .index("email", ["email"]) + .index('name', ['name']) + .index("phone", ["phone"]), + ...applicationTables, }); diff --git a/packages/ui/package.json b/packages/ui/package.json index 9ed7000..a996ded 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts", + ".": "./src/index.tsx", "./avatar": "./src/avatar.tsx", "./based-avatar": "./src/based-avatar.tsx", "./based-progress": "./src/based-progress.tsx", diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx index d9aa985..c9b39fb 100644 --- a/packages/ui/src/avatar.tsx +++ b/packages/ui/src/avatar.tsx @@ -1,9 +1,9 @@ 'use client'; -import * as React from 'react'; +import type * as React from 'react'; import * as AvatarPrimitive from '@radix-ui/react-avatar'; -import { cn } from '@gib/ui'; +import { cn } from '.'; function Avatar({ className, diff --git a/packages/ui/src/based-avatar.tsx b/packages/ui/src/based-avatar.tsx index 99a6b6a..fbe6d1c 100644 --- a/packages/ui/src/based-avatar.tsx +++ b/packages/ui/src/based-avatar.tsx @@ -1,8 +1,7 @@ 'use client'; -import { type ComponentProps } from 'react'; -import { AvatarImage } from '@/components/ui/avatar'; -import { cn } from '@/lib/utils'; +import type { ComponentProps } from 'react'; +import { cn, AvatarImage } from '@gib/ui'; import * as AvatarPrimitive from '@radix-ui/react-avatar'; import { User } from 'lucide-react'; @@ -58,7 +57,7 @@ const BasedAvatar = ({ ) : ( )} diff --git a/packages/ui/src/based-progress.tsx b/packages/ui/src/based-progress.tsx index a9f9c7a..55d4c87 100644 --- a/packages/ui/src/based-progress.tsx +++ b/packages/ui/src/based-progress.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import * as ProgressPrimitive from '@radix-ui/react-progress'; type BasedProgressProps = React.ComponentProps< diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index f0fe311..35b0bbf 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -1,6 +1,6 @@ import type { VariantProps } from 'class-variance-authority'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import { Slot } from '@radix-ui/react-slot'; import { cva } from 'class-variance-authority'; diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx index c45ffdb..f7033e1 100644 --- a/packages/ui/src/card.tsx +++ b/packages/ui/src/card.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; function Card({ className, ...props }: React.ComponentProps<'div'>) { return ( diff --git a/packages/ui/src/checkbox.tsx b/packages/ui/src/checkbox.tsx index a966333..612a836 100644 --- a/packages/ui/src/checkbox.tsx +++ b/packages/ui/src/checkbox.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import { CheckIcon } from 'lucide-react'; diff --git a/packages/ui/src/drawer.tsx b/packages/ui/src/drawer.tsx index 693ba23..6f60f52 100644 --- a/packages/ui/src/drawer.tsx +++ b/packages/ui/src/drawer.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import { Drawer as DrawerPrimitive } from 'vaul'; function Drawer({ diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx index 68fbde8..e7dc19c 100644 --- a/packages/ui/src/dropdown-menu.tsx +++ b/packages/ui/src/dropdown-menu.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; diff --git a/packages/ui/src/field.tsx b/packages/ui/src/field.tsx index 3b3e95b..d613531 100644 --- a/packages/ui/src/field.tsx +++ b/packages/ui/src/field.tsx @@ -2,9 +2,7 @@ import type { VariantProps } from 'class-variance-authority'; import { useMemo } from 'react'; -import { cn } from '@acme/ui'; -import { Label } from '@acme/ui/label'; -import { Separator } from '@acme/ui/separator'; +import { cn, Label, Separator } from '@gib/ui'; import { cva } from 'class-variance-authority'; export function FieldSet({ diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx index 802b5d0..ec64a86 100644 --- a/packages/ui/src/form.tsx +++ b/packages/ui/src/form.tsx @@ -2,9 +2,8 @@ import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'; import * as React from 'react'; -import { Label } from '@/components/ui/label'; -import { cn } from '@/lib/utils'; -import * as LabelPrimitive from '@radix-ui/react-label'; +import { cn, Label } from '@gib/ui'; +import type * as LabelPrimitive from '@radix-ui/react-label'; import { Slot } from '@radix-ui/react-slot'; import { Controller, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts deleted file mode 100644 index f5544bf..0000000 --- a/packages/ui/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { cx } from 'class-variance-authority'; -import { twMerge } from 'tailwind-merge'; - -export const cn = (...inputs: Parameters) => twMerge(cx(inputs)); - -export const ccn = ({ - context, - className, - on = '', - off = '', -}: { - context: boolean; - className: string; - on: string; - off: string; -}) => twMerge(className, context ? on : off); diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx new file mode 100644 index 0000000..79b6b41 --- /dev/null +++ b/packages/ui/src/index.tsx @@ -0,0 +1,109 @@ +import { cx } from 'class-variance-authority'; +import { twMerge } from 'tailwind-merge'; + +export const cn = (...inputs: Parameters) => twMerge(cx(inputs)); + +export const ccn = ({ + context, + className, + on = '', + off = '', +}: { + context: boolean; + className: string; + on: string; + off: string; +}) => twMerge(className, context ? on : off); + +export { Avatar, AvatarImage, AvatarFallback } from './avatar'; +export { BasedAvatar } from './based-avatar'; +export { BasedProgress } from './based-progress'; +export { Button, buttonVariants } from './button'; +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} from './card'; +export { Checkbox } from './checkbox'; +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} from './drawer'; +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './dropdown-menu'; +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} from './form'; +export { + type ImageCropProps, + type ImageCropApplyProps, + type ImageCropContentProps, + type ImageCropResetProps, + type CropperProps, + Cropper, + ImageCrop, + ImageCropApply, + ImageCropContent, + ImageCropReset, +} from './shadcn-io/image-crop'; +export { Input } from './input'; +export { + InputOTP, + InputOTPGroup, + InputOTPSlot, + InputOTPSeparator, +} from './input-otp'; +export { Label } from './label'; +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +} from './pagination'; +export { Progress } from './progress'; +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, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './table'; +export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'; +export { Toaster } from './sonner'; +export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './theme'; diff --git a/packages/ui/src/input-otp.tsx b/packages/ui/src/input-otp.tsx index c6a5f11..a979fab 100644 --- a/packages/ui/src/input-otp.tsx +++ b/packages/ui/src/input-otp.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import { OTPInput, OTPInputContext } from 'input-otp'; import { MinusIcon } from 'lucide-react'; diff --git a/packages/ui/src/input.tsx b/packages/ui/src/input.tsx index 5d86f89..1e47043 100644 --- a/packages/ui/src/input.tsx +++ b/packages/ui/src/input.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; function Input({ className, type, ...props }: React.ComponentProps<'input'>) { return ( diff --git a/packages/ui/src/label.tsx b/packages/ui/src/label.tsx index 3ca5388..a691a05 100644 --- a/packages/ui/src/label.tsx +++ b/packages/ui/src/label.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import * as LabelPrimitive from '@radix-ui/react-label'; function Label({ diff --git a/packages/ui/src/pagination.tsx b/packages/ui/src/pagination.tsx index e2c10a0..88a9fba 100644 --- a/packages/ui/src/pagination.tsx +++ b/packages/ui/src/pagination.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; +import type * as React from 'react'; +import type { Button } from '@gib/ui'; +import { cn, buttonVariants } from '@gib/ui'; import { ChevronLeftIcon, ChevronRightIcon, diff --git a/packages/ui/src/progress.tsx b/packages/ui/src/progress.tsx index a225c68..a52ef7e 100644 --- a/packages/ui/src/progress.tsx +++ b/packages/ui/src/progress.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import type * as React from 'react'; +import { cn } from '@gib/ui'; import * as ProgressPrimitive from '@radix-ui/react-progress'; function Progress({ diff --git a/packages/ui/src/scroll-area.tsx b/packages/ui/src/scroll-area.tsx index d56cc8d..d3198e5 100644 --- a/packages/ui/src/scroll-area.tsx +++ b/packages/ui/src/scroll-area.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import type * as React from 'react'; +import { cn } from '@gib/ui'; import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; function ScrollArea({ diff --git a/packages/ui/src/separator.tsx b/packages/ui/src/separator.tsx index fe1dba0..b56124d 100644 --- a/packages/ui/src/separator.tsx +++ b/packages/ui/src/separator.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@gib/ui'; import * as SeparatorPrimitive from '@radix-ui/react-separator'; function Separator({ diff --git a/packages/ui/src/shadcn-io/image-crop/index.tsx b/packages/ui/src/shadcn-io/image-crop/index.tsx index b0d38a4..30de938 100644 --- a/packages/ui/src/shadcn-io/image-crop/index.tsx +++ b/packages/ui/src/shadcn-io/image-crop/index.tsx @@ -17,8 +17,7 @@ import { useRef, useState, } from 'react'; -import { Button } from '@/components/ui'; -import { cn } from '@/lib/utils'; +import { cn, Button } from '@gib/ui'; import { CropIcon, RotateCcwIcon } from 'lucide-react'; import { Slot } from 'radix-ui'; import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop'; @@ -94,7 +93,7 @@ const getCroppedPngImage = async ( return croppedImageUrl; }; -type ImageCropContextType = { +interface ImageCropContextType { file: File; maxImageSize: number; imgSrc: string; @@ -150,7 +149,7 @@ export const ImageCrop = ({ useEffect(() => { const reader = new FileReader(); reader.addEventListener('load', () => - setImgSrc(reader.result?.toString() || ''), + setImgSrc(reader.result?.toString() ?? ''), ); reader.readAsDataURL(file); }, [file]); diff --git a/packages/ui/src/sonner.tsx b/packages/ui/src/sonner.tsx index 564a654..3fd387a 100644 --- a/packages/ui/src/sonner.tsx +++ b/packages/ui/src/sonner.tsx @@ -1,7 +1,8 @@ 'use client'; import { useTheme } from 'next-themes'; -import { Toaster as Sonner, ToasterProps } from 'sonner'; +import type { ToasterProps } from 'sonner'; +import { Toaster as Sonner } from 'sonner'; const Toaster = ({ ...props }: ToasterProps) => { const { theme = 'system' } = useTheme(); diff --git a/packages/ui/src/status-message.tsx b/packages/ui/src/status-message.tsx index 6ffb9ad..3014a84 100644 --- a/packages/ui/src/status-message.tsx +++ b/packages/ui/src/status-message.tsx @@ -1,9 +1,9 @@ -import { type ComponentProps } from 'react'; -import { cn } from '@/lib/utils'; +import type { ComponentProps } from 'react'; +import { cn } from '@gib/ui'; type Message = { success: string } | { error: string } | { message: string }; -type StatusMessageProps = { +interface StatusMessageProps { message: Message; containerProps?: ComponentProps<'div'>; textProps?: ComponentProps<'div'>; diff --git a/packages/ui/src/submit-button.tsx b/packages/ui/src/submit-button.tsx index 01b48ce..daf3cc0 100644 --- a/packages/ui/src/submit-button.tsx +++ b/packages/ui/src/submit-button.tsx @@ -1,8 +1,7 @@ 'use client'; -import { type ComponentProps } from 'react'; -import { Button } from '@/components/ui'; -import { cn } from '@/lib/utils'; +import type { ComponentProps } from 'react'; +import { cn, Button } from '@gib/ui'; import { Loader2 } from 'lucide-react'; import { useFormStatus } from 'react-dom'; diff --git a/packages/ui/src/switch.tsx b/packages/ui/src/switch.tsx index d50d9c9..9dd50c6 100644 --- a/packages/ui/src/switch.tsx +++ b/packages/ui/src/switch.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import type * as React from 'react'; +import { cn } from '@gib/ui'; import * as SwitchPrimitive from '@radix-ui/react-switch'; function Switch({ diff --git a/packages/ui/src/table.tsx b/packages/ui/src/table.tsx index 76dccf4..96eaec2 100644 --- a/packages/ui/src/table.tsx +++ b/packages/ui/src/table.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import type * as React from 'react'; +import { cn } from '@gib/ui'; function Table({ className, ...props }: React.ComponentProps<'table'>) { return ( diff --git a/packages/ui/src/tabs.tsx b/packages/ui/src/tabs.tsx index 6322ec0..de34edc 100644 --- a/packages/ui/src/tabs.tsx +++ b/packages/ui/src/tabs.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import type * as React from 'react'; +import { cn } from '@gib/ui'; import * as TabsPrimitive from '@radix-ui/react-tabs'; function Tabs({ diff --git a/packages/ui/src/theme.tsx b/packages/ui/src/theme.tsx index ed66254..d433e96 100644 --- a/packages/ui/src/theme.tsx +++ b/packages/ui/src/theme.tsx @@ -1,184 +1,70 @@ 'use client'; -import * as React from 'react'; -import { DesktopIcon, MoonIcon, SunIcon } from '@radix-ui/react-icons'; -import * as z from 'zod/v4'; +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 { Button } from './button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from './dropdown-menu'; +import { Button, cn } from '@gib/ui'; -const ThemeModeSchema = z.enum(['light', 'dark', 'auto']); +const ThemeProvider = ({ + children, + ...props +}: ComponentProps) => { + const [mounted, setMounted] = useState(false); -const themeKey = 'theme-mode'; + useEffect(() => { + setMounted(true); + }, []); -export type ThemeMode = z.output; -export type ResolvedTheme = Exclude; + if (!mounted) return null; + return {children}; +}; -const getStoredThemeMode = (): ThemeMode => { - if (typeof window === 'undefined') return 'auto'; - try { - const storedTheme = localStorage.getItem(themeKey); - return ThemeModeSchema.parse(storedTheme); - } catch { - return 'auto'; +interface ThemeToggleProps { + size?: number; + buttonProps?: Omit, 'onClick'>; +}; + +const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => { + const { setTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); } -}; -const setStoredThemeMode = (theme: ThemeMode) => { - try { - const parsedTheme = ThemeModeSchema.parse(theme); - localStorage.setItem(themeKey, parsedTheme); - } catch { - // Silently fail if localStorage is unavailable - } -}; - -const getSystemTheme = () => { - if (typeof window === 'undefined') return 'light'; - return window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; -}; - -const updateThemeClass = (themeMode: ThemeMode) => { - const root = document.documentElement; - root.classList.remove('light', 'dark', 'auto'); - const newTheme = themeMode === 'auto' ? getSystemTheme() : themeMode; - root.classList.add(newTheme); - - if (themeMode === 'auto') { - root.classList.add('auto'); - } -}; - -const setupPreferredListener = () => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = () => updateThemeClass('auto'); - mediaQuery.addEventListener('change', handler); - return () => mediaQuery.removeEventListener('change', handler); -}; - -const getNextTheme = (current: ThemeMode): ThemeMode => { - const themes: ThemeMode[] = - getSystemTheme() === 'dark' - ? ['auto', 'light', 'dark'] - : ['auto', 'dark', 'light']; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return themes[(themes.indexOf(current) + 1) % themes.length]!; -}; - -export const themeDetectorScript = (function () { - function themeFn() { - const isValidTheme = (theme: string): theme is ThemeMode => { - const validThemes = ['light', 'dark', 'auto'] as const; - return validThemes.includes(theme as ThemeMode); - }; - - const storedTheme = localStorage.getItem('theme-mode') ?? 'auto'; - const validTheme = isValidTheme(storedTheme) ? storedTheme : 'auto'; - - if (validTheme === 'auto') { - const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') - .matches - ? 'dark' - : 'light'; - document.documentElement.classList.add(autoTheme, 'auto'); - } else { - document.documentElement.classList.add(validTheme); - } - } - return `(${themeFn.toString()})();`; -})(); - -interface ThemeContextProps { - themeMode: ThemeMode; - resolvedTheme: ResolvedTheme; - setTheme: (theme: ThemeMode) => void; - toggleMode: () => void; -} -const ThemeContext = React.createContext( - undefined, -); - -export function ThemeProvider({ children }: React.PropsWithChildren) { - const [themeMode, setThemeMode] = React.useState(getStoredThemeMode); - - React.useEffect(() => { - if (themeMode !== 'auto') return; - return setupPreferredListener(); - }, [themeMode]); - - const resolvedTheme = themeMode === 'auto' ? getSystemTheme() : themeMode; - - const setTheme = (newTheme: ThemeMode) => { - setThemeMode(newTheme); - setStoredThemeMode(newTheme); - updateThemeClass(newTheme); - }; - - const toggleMode = () => { - setTheme(getNextTheme(themeMode)); + const toggleTheme = () => { + if (resolvedTheme === 'dark') setTheme('light'); + else setTheme('dark'); }; return ( - -