From 9e1d40333c87510f31ecbe4831c93731a0fb4b62 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Thu, 4 Sep 2025 16:40:16 -0500 Subject: [PATCH] start on statuses table list thing --- bun.lock | 11 +- convex/auth.ts | 17 ++- convex/schema.ts | 8 +- convex/statuses.ts | 14 +- package.json | 3 + src/app/(auth)/profile/page.tsx | 2 +- src/app/(auth)/signin/page.tsx | 68 +++++---- src/app/page.tsx | 21 ++- .../layout/header/controls/AvatarDropdown.tsx | 6 +- .../layout/profile/avatar-upload.tsx | 6 +- .../layout/profile/reset-password.tsx | 67 +++++---- src/components/layout/profile/sign-out.tsx | 7 +- src/components/layout/profile/user-info.tsx | 37 +++-- src/components/layout/status/list/index.tsx | 109 ++++++++++++++ src/components/ui/based-progress.tsx | 51 +++++++ src/components/ui/drawer.tsx | 135 ++++++++++++++++++ src/components/ui/index.tsx | 14 ++ src/components/ui/progress.tsx | 31 ++++ src/lib/utils.ts | 16 +++ 19 files changed, 498 insertions(+), 125 deletions(-) create mode 100644 src/components/layout/status/list/index.tsx create mode 100644 src/components/ui/based-progress.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/progress.tsx diff --git a/bun.lock b/bun.lock index b0ec268..6526a1e 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,10 @@ "@convex-dev/auth": "^0.0.81", "@hookform/resolvers": "^5.2.1", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", @@ -29,12 +31,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "typescript-eslint": "^8.42.0", + "vaul": "^1.1.2", "zod": "^4.1.5", }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@tailwindcss/postcss": "^4.1.12", - "@types/node": "^20.19.12", + "@types/node": "^20.19.13", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "dotenv": "^16.6.1", @@ -555,6 +558,8 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], @@ -579,6 +584,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], @@ -1705,6 +1712,8 @@ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], diff --git a/convex/auth.ts b/convex/auth.ts index 34d65cf..12e0daa 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -21,8 +21,8 @@ export const getUser = query(async (ctx) => { if (!user) throw new ConvexError('User not found.'); const image: Id<'_storage'> | null = typeof user.image === 'string' && user.image.length > 0 - ? user.image as Id<'_storage'> - : null + ? (user.image as Id<'_storage'>) + : null; return { id: user._id, email: user.email ?? null, @@ -56,7 +56,7 @@ export const updateUserEmail = mutation({ if (!user) throw new ConvexError('User not found.'); await ctx.db.patch(userId, { email }); return { success: true }; - } + }, }); export const updateUserImage = mutation({ @@ -70,8 +70,7 @@ export const updateUserImage = mutation({ if (!user) throw new ConvexError('User not found.'); const oldImage = user.image as Id<'_storage'> | undefined; await ctx.db.patch(userId, { image: storageId }); - if (oldImage && oldImage !== storageId) - await ctx.storage.delete(oldImage); + if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage); return { success: true }; }, }); @@ -94,14 +93,14 @@ export const updateUserPassword = action({ currentPassword: v.string(), newPassword: v.string(), }, - handler: async (ctx, {currentPassword, newPassword}) => { + 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); if (!user?.email) throw new ConvexError('User not found.'); const verified = await retrieveAccount(ctx, { provider: 'password', - account: { id: user.email, secret: currentPassword } + account: { id: user.email, secret: currentPassword }, }); if (!verified) throw new ConvexError('Current password is incorrect.'); @@ -110,9 +109,9 @@ export const updateUserPassword = action({ await modifyAccountCredentials(ctx, { provider: 'password', - account: { id: user.email, secret: newPassword } + account: { id: user.email, secret: newPassword }, }); return { success: true }; }, -}) +}); diff --git a/convex/schema.ts b/convex/schema.ts index 7e31b7a..fb6ae8f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -17,14 +17,14 @@ export default defineSchema({ phoneVerificationTime: v.optional(v.number()), isAnonymous: v.optional(v.boolean()), }) - .index("email", ["email"]) - .index("phone", ["phone"]), + .index('email', ['email']) + .index('phone', ['phone']), statuses: defineTable({ userId: v.id('users'), message: v.string(), updatedAt: v.number(), updatedBy: v.id('users'), }) - .index('by_user', ['userId']) - .index('by_user_updatedAt', ['userId', 'updatedAt']), + .index('by_user', ['userId']) + .index('by_user_updatedAt', ['userId', 'updatedAt']), }); diff --git a/convex/statuses.ts b/convex/statuses.ts index 308f652..b4bfa98 100644 --- a/convex/statuses.ts +++ b/convex/statuses.ts @@ -11,22 +11,16 @@ import type { MutationCtx, QueryCtx } from './_generated/server'; type RWCtx = MutationCtx | QueryCtx; // CHANGED: typed helpers -const ensureUser = async ( - ctx: RWCtx, - userId: Id<'users'>, -) => { +const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => { const user = await ctx.db.get(userId); if (!user) throw new ConvexError('User not found.'); return user; }; -const latestStatusForOwner = async ( - ctx: RWCtx, - ownerId: Id<'users'>, -) => { +const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => { const [latest] = await ctx.db .query('statuses') - .withIndex('by_user_updatedAt', q => q.eq('userId', ownerId)) + .withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId)) .order('desc') .take(1); return latest as Doc<'statuses'> | null; @@ -180,7 +174,7 @@ export const listHistoryByUser = query({ return await ctx.db .query('statuses') - .withIndex('by_user_updatedAt', q => q.eq('userId', userId)) + .withIndex('by_user_updatedAt', (q) => q.eq('userId', userId)) .order('desc') .paginate(paginationOpts); }, diff --git a/package.json b/package.json index 53e38ff..a8452f3 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ "@convex-dev/auth": "^0.0.81", "@hookform/resolvers": "^5.2.1", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", @@ -43,6 +45,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "typescript-eslint": "^8.42.0", + "vaul": "^1.1.2", "zod": "^4.1.5" }, "devDependencies": { diff --git a/src/app/(auth)/profile/page.tsx b/src/app/(auth)/profile/page.tsx index dc89e1c..28655a7 100644 --- a/src/app/(auth)/profile/page.tsx +++ b/src/app/(auth)/profile/page.tsx @@ -7,7 +7,7 @@ import { ProfileHeader, ResetPasswordForm, SignOutForm, - UserInfoForm + UserInfoForm, } from '@/components/layout/profile'; const Profile = async () => { diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx index 016c6c3..ad60329 100644 --- a/src/app/(auth)/signin/page.tsx +++ b/src/app/(auth)/signin/page.tsx @@ -29,40 +29,46 @@ const signInFormSchema = z.object({ email: z.email({ message: 'Please enter a valid email address.', }), - password: z.string() - .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, { - message: 'Incorrect password. Does not meet requirements.' - }) + password: z + .string() + .regex( + /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, + { + message: 'Incorrect password. Does not meet requirements.', + }, + ), }); -const signUpFormSchema = z.object({ - name: z.string().min(2, { - message: 'Name must be at least 2 characters.', - }), - email: z.email({ - message: 'Please enter a valid email address.', - }), - password: z.string() - .min(8, { message: 'Password must be at least 8 characters.' }) - .max(100, { message: 'Password must be no more than 100 characters.' }) - .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(/[@#$%^&+=]/, { - message: 'Password must contain at least one special character.' +const signUpFormSchema = z + .object({ + name: z.string().min(2, { + message: 'Name must be at least 2 characters.', }), - confirmPassword: z.string().min(8, { - message: 'Password must be at least 8 characters.', - }), -}) -.refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match!', - path: ['confirmPassword'], -}); + email: z.email({ + message: 'Please enter a valid email address.', + }), + password: z + .string() + .min(8, { message: 'Password must be at least 8 characters.' }) + .max(100, { message: 'Password must be no more than 100 characters.' }) + .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(/[@#$%^&+=]/, { + message: 'Password must contain at least one special character.', + }), + confirmPassword: z.string().min(8, { + message: 'Password must be at least 8 characters.', + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], + }); export default function SignIn() { const { signIn } = useAuthActions(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 160f74e..8e858bd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,21 @@ -'use client'; - -import { useConvexAuth, useMutation, useQuery } from 'convex/react'; +'use server'; +import { preloadQuery } from 'convex/nextjs'; import { api } from '~/convex/_generated/api'; +import { StatusList } from '@/components/layout/status/list' +import { useConvexAuth, useMutation, useQuery } from 'convex/react'; import Link from 'next/link'; import { useAuthActions } from '@convex-dev/auth/react'; -import { useRouter } from 'next/navigation'; -const Home = () => { - return
; +const Home = async () => { + const preloadedUser = await preloadQuery(api.auth.getUser); + const preloadedStatuses = await preloadQuery(api.statuses.getCurrentForAll) + return ( +
+ +
+ ); }; export default Home; diff --git a/src/components/layout/header/controls/AvatarDropdown.tsx b/src/components/layout/header/controls/AvatarDropdown.tsx index 7e32f58..95fabf8 100644 --- a/src/components/layout/header/controls/AvatarDropdown.tsx +++ b/src/components/layout/header/controls/AvatarDropdown.tsx @@ -27,11 +27,7 @@ export const AvatarDropdown = () => { ); if (isLoading) - return ( - - ); + return ; if (!isAuthenticated) return
; return ( diff --git a/src/components/layout/profile/avatar-upload.tsx b/src/components/layout/profile/avatar-upload.tsx index 534358b..7e61dd8 100644 --- a/src/components/layout/profile/avatar-upload.tsx +++ b/src/components/layout/profile/avatar-upload.tsx @@ -47,10 +47,12 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { body: file, }); if (!result.ok) { - const msg = await result.text().catch(() => 'Upload failed.') + const msg = await result.text().catch(() => 'Upload failed.'); throw new Error(msg); } - const uploadResponse = await result.json() as { storageId: Id<'_storage'> }; + const uploadResponse = (await result.json()) as { + storageId: Id<'_storage'>; + }; await updateUserImage({ storageId: uploadResponse.storageId }); toast.success('Profile picture updated.'); } catch (error) { diff --git a/src/components/layout/profile/reset-password.tsx b/src/components/layout/profile/reset-password.tsx index b990b12..59e913d 100644 --- a/src/components/layout/profile/reset-password.tsx +++ b/src/components/layout/profile/reset-password.tsx @@ -22,34 +22,42 @@ import { } from '@/components/ui'; import { toast } from 'sonner'; -const formSchema = z.object({ - currentPassword: z.string() - .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, { - message: 'Incorrect current password. Does not meet requirements.', - }), - newPassword: z.string() - .min(8, { message: 'New password must be at least 8 characters.' }) - .max(100, { message: 'New password must be less than 100 characters.' }) - .regex(/[0-9]/, { message: 'New password must contain at least one digit.' }) - .regex(/[a-z]/, { - message: 'New password must contain at least one lowercase letter.' - }) - .regex(/[A-Z]/, { - message: 'New password must contain at least one uppercase letter.' - }) - .regex(/[@#$%^&+=]/, { - message: 'New password must contain at least one special character.' - }), - confirmPassword: z.string(), -}) -.refine((data) => data.currentPassword !== data.newPassword, { - message: 'New password must be different from current password.', - path: ['newPassword'], -}) -.refine((data) => data.newPassword === data.confirmPassword, { - message: 'Passwords do not match.', - path: ['confirmPassword'], -}); +const formSchema = z + .object({ + currentPassword: z + .string() + .regex( + /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, + { + message: 'Incorrect current password. Does not meet requirements.', + }, + ), + newPassword: z + .string() + .min(8, { message: 'New password must be at least 8 characters.' }) + .max(100, { message: 'New password must be less than 100 characters.' }) + .regex(/[0-9]/, { + message: 'New password must contain at least one digit.', + }) + .regex(/[a-z]/, { + message: 'New password must contain at least one lowercase letter.', + }) + .regex(/[A-Z]/, { + message: 'New password must contain at least one uppercase letter.', + }) + .regex(/[@#$%^&+=]/, { + message: 'New password must contain at least one special character.', + }), + confirmPassword: z.string(), + }) + .refine((data) => data.currentPassword !== data.newPassword, { + message: 'New password must be different from current password.', + path: ['newPassword'], + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }); export const ResetPasswordForm = () => { const [loading, setLoading] = useState(false); @@ -80,7 +88,7 @@ export const ResetPasswordForm = () => { console.error('Error updating password:', error); toast.error('Error updating password.'); } finally { - setLoading(false) + setLoading(false); } }; @@ -161,4 +169,3 @@ export const ResetPasswordForm = () => { ); }; - diff --git a/src/components/layout/profile/sign-out.tsx b/src/components/layout/profile/sign-out.tsx index b58763a..b67895c 100644 --- a/src/components/layout/profile/sign-out.tsx +++ b/src/components/layout/profile/sign-out.tsx @@ -1,10 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; import { useAuthActions } from '@convex-dev/auth/react'; -import { - CardHeader, - SubmitButton, -} from '@/components/ui'; +import { CardHeader, SubmitButton } from '@/components/ui'; export const SignOutForm = () => { const { signOut } = useAuthActions(); @@ -22,4 +19,4 @@ export const SignOutForm = () => {
); -} +}; diff --git a/src/components/layout/profile/user-info.tsx b/src/components/layout/profile/user-info.tsx index 89185e3..b6787ff 100644 --- a/src/components/layout/profile/user-info.tsx +++ b/src/components/layout/profile/user-info.tsx @@ -1,10 +1,6 @@ 'use client'; import { useState } from 'react'; -import { - type Preloaded, - usePreloadedQuery, - useMutation, -} from 'convex/react'; +import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react'; import { api } from '~/convex/_generated/api'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -24,16 +20,17 @@ import { import { toast } from 'sonner'; const formSchema = z.object({ - name: z.string() - .trim() - .min(5, { - message: 'Full name is required & must be at least 5 characters.' - }) - .max(50, { - message: 'Full name must be less than 50 characters.' - }), + name: z + .string() + .trim() + .min(5, { + message: 'Full name is required & must be at least 5 characters.', + }) + .max(50, { + message: 'Full name must be less than 50 characters.', + }), email: z.email({ - message: 'Please enter a valid email address.' + message: 'Please enter a valid email address.', }), }); @@ -53,26 +50,24 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => { defaultValues: { name: user?.name ?? '', email: user?.email ?? '', - } + }, }); const handleSubmit = async (values: z.infer) => { const ops: Promise[] = []; const name = values.name.trim(); const email = values.email.trim().toLowerCase(); - if (name !== (user?.name ?? '')) - ops.push(updateUserName({name})); - if (email !== (user?.email ?? '')) - ops.push(updateUserEmail({email})); + 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 Promise.all(ops); - form.reset({ name, email}); + form.reset({ name, email }); toast.success('Profile updated successfully.'); } catch (error) { console.error(error); - toast.error('Error updating profile.') + toast.error('Error updating profile.'); } finally { setLoading(false); } diff --git a/src/components/layout/status/list/index.tsx b/src/components/layout/status/list/index.tsx new file mode 100644 index 0000000..cb9ca70 --- /dev/null +++ b/src/components/layout/status/list/index.tsx @@ -0,0 +1,109 @@ +'use client'; +import Link from 'next/link'; +import { useState } from 'react'; +import { + type Preloaded, + usePreloadedQuery, +} from 'convex/react'; +import { api } from '~/convex/_generated/api'; +import { useTVMode } from '@/components/providers'; +import { + BasedAvatar, + Button, + Card, + CardContent, + Drawer, + DrawerTrigger, + Input, + SubmitButton, +} from '@/components/ui'; +import { toast } from 'sonner'; +import { ccn, formatTime, formatDate } from '@/lib/utils'; +import { RefreshCw, Clock, Calendar, CheckCircle2 } from 'lucide-react'; + +type StatusListProps = { + preloadedUser: Preloaded; + preloadedStatuses: Preloaded; +}; + +export const StatusList = ({ + preloadedUser, + preloadedStatuses, +}: StatusListProps) => { + const user = usePreloadedQuery(preloadedUser); + const statuses = usePreloadedQuery(preloadedStatuses); + const { tvMode } = useTVMode(); + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [statusInput, setStatusInput] = useState(''); + + const handleSelectAllClick = () => { + if (selectAll) setSelectedUsers([]); + else setSelectedUsers([]); + setSelectAll(!selectAll); + }; + + const containerCn = ccn({ + context: tvMode, + className: 'flex flex-col mx-auto items-center\ + sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]', + on: 'mt-8', + off: 'px-10', + }); + + const headerCn = ccn({ + context: tvMode, + className: 'w-full', + on: 'hidden', + off: 'flex mb-3 justify-between items-center', + }); + + const selectAllIconCn = ccn({ + context: selectAll, + className: 'w-4 h-4', + on: '', + off: '', + }) + + const cardContainerCn = ccn({ + context: tvMode, + className: 'w-full space-y-2', + on: 'text-primary', + off: '', + }); + + return ( +
+
+
+ + {!tvMode && ( +
+ Miss the old table? + + Find it here! + +
+ )} +
+
+ +
+
+ +
+ ); +}; diff --git a/src/components/ui/based-progress.tsx b/src/components/ui/based-progress.tsx new file mode 100644 index 0000000..714d468 --- /dev/null +++ b/src/components/ui/based-progress.tsx @@ -0,0 +1,51 @@ +'use client'; + +import * as React from 'react'; +import * as ProgressPrimitive from '@radix-ui/react-progress'; +import { cn } from '@/lib/utils'; + +type BasedProgressProps = React.ComponentProps & { + /** how many ms between updates */ + intervalMs?: number; + /** fraction of the remaining distance to add each tick */ + alpha?: number; +}; + +const BasedProgress = ({ + intervalMs = 50, + alpha = 0.1, + className, + value = 0, + ...props +}: BasedProgressProps) => { + const [progress, setProgress] = React.useState(value ?? 0); + + React.useEffect(() => { + const id = window.setInterval(() => { + setProgress((prev) => { + const next = prev + (100 - prev) * alpha; + return Math.min(100, Math.round(next * 10) / 10); + }); + }, intervalMs); + return () => window.clearInterval(id); + }, [intervalMs, alpha]); + + return ( + + + + ); +}; + +export { BasedProgress }; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..8aa6923 --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,135 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index abf9cd8..d7e8d59 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -1,5 +1,6 @@ export { Avatar, AvatarImage, AvatarFallback } from './avatar'; export { BasedAvatar } from './based-avatar'; +export { BasedProgress } from './based-progress'; export { Button, buttonVariants } from './button'; export { Card, @@ -10,6 +11,18 @@ export { CardDescription, CardContent, } from './card'; +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} from './drawer'; export { DropdownMenu, DropdownMenuContent, @@ -30,6 +43,7 @@ export { } from './form'; export { Input } from './input'; export { Label } from './label'; +export { Progress } from './progress'; export { Separator } from './separator'; export { StatusMessage } from './status-message'; export { SubmitButton } from './submit-button'; diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..f89bd85 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import * as ProgressPrimitive from '@radix-ui/react-progress'; + +import { cn } from '@/lib/utils'; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 63dfbed..6fbb004 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -18,3 +18,19 @@ export const ccn = ({ }) => { return twMerge(className, context ? on : off); }; + +export const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: 'numeric', + }); + return time; +}; + +export const formatDate = (timestamp: string) => { + const date = new Date(timestamp); + const day = date.getDate(); + const month = date.toLocaleString('default', { month: 'long' }); + return `${month} ${day}`; +};