start on statuses table list thing

This commit is contained in:
2025-09-04 16:40:16 -05:00
parent 56ea3e0904
commit 9e1d40333c
19 changed files with 498 additions and 125 deletions

View File

@@ -7,8 +7,10 @@
"@convex-dev/auth": "^0.0.81", "@convex-dev/auth": "^0.0.81",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@radix-ui/react-avatar": "^1.1.10", "@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-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
@@ -29,12 +31,13 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.42.0", "typescript-eslint": "^8.42.0",
"vaul": "^1.1.2",
"zod": "^4.1.5", "zod": "^4.1.5",
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.12",
"@types/node": "^20.19.12", "@types/node": "^20.19.13",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"dotenv": "^16.6.1", "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-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-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=="], "@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-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-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=="], "@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=="], "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-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=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],

View File

@@ -21,8 +21,8 @@ export const getUser = query(async (ctx) => {
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const image: Id<'_storage'> | null = const image: Id<'_storage'> | null =
typeof user.image === 'string' && user.image.length > 0 typeof user.image === 'string' && user.image.length > 0
? user.image as Id<'_storage'> ? (user.image as Id<'_storage'>)
: null : null;
return { return {
id: user._id, id: user._id,
email: user.email ?? null, email: user.email ?? null,
@@ -56,7 +56,7 @@ export const updateUserEmail = mutation({
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
await ctx.db.patch(userId, { email }); await ctx.db.patch(userId, { email });
return { success: true }; return { success: true };
} },
}); });
export const updateUserImage = mutation({ export const updateUserImage = mutation({
@@ -70,8 +70,7 @@ export const updateUserImage = mutation({
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const oldImage = user.image as Id<'_storage'> | undefined; const oldImage = user.image as Id<'_storage'> | undefined;
await ctx.db.patch(userId, { image: storageId }); await ctx.db.patch(userId, { image: storageId });
if (oldImage && oldImage !== storageId) if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage);
await ctx.storage.delete(oldImage);
return { success: true }; return { success: true };
}, },
}); });
@@ -94,14 +93,14 @@ export const updateUserPassword = action({
currentPassword: v.string(), currentPassword: v.string(),
newPassword: v.string(), newPassword: v.string(),
}, },
handler: async (ctx, {currentPassword, newPassword}) => { handler: async (ctx, { currentPassword, newPassword }) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.'); if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.runQuery(api.auth.getUser); const user = await ctx.runQuery(api.auth.getUser);
if (!user?.email) throw new ConvexError('User not found.'); if (!user?.email) throw new ConvexError('User not found.');
const verified = await retrieveAccount(ctx, { const verified = await retrieveAccount(ctx, {
provider: 'password', provider: 'password',
account: { id: user.email, secret: currentPassword } account: { id: user.email, secret: currentPassword },
}); });
if (!verified) throw new ConvexError('Current password is incorrect.'); if (!verified) throw new ConvexError('Current password is incorrect.');
@@ -110,9 +109,9 @@ export const updateUserPassword = action({
await modifyAccountCredentials(ctx, { await modifyAccountCredentials(ctx, {
provider: 'password', provider: 'password',
account: { id: user.email, secret: newPassword } account: { id: user.email, secret: newPassword },
}); });
return { success: true }; return { success: true };
}, },
}) });

View File

@@ -17,14 +17,14 @@ export default defineSchema({
phoneVerificationTime: v.optional(v.number()), phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()), isAnonymous: v.optional(v.boolean()),
}) })
.index("email", ["email"]) .index('email', ['email'])
.index("phone", ["phone"]), .index('phone', ['phone']),
statuses: defineTable({ statuses: defineTable({
userId: v.id('users'), userId: v.id('users'),
message: v.string(), message: v.string(),
updatedAt: v.number(), updatedAt: v.number(),
updatedBy: v.id('users'), updatedBy: v.id('users'),
}) })
.index('by_user', ['userId']) .index('by_user', ['userId'])
.index('by_user_updatedAt', ['userId', 'updatedAt']), .index('by_user_updatedAt', ['userId', 'updatedAt']),
}); });

View File

@@ -11,22 +11,16 @@ import type { MutationCtx, QueryCtx } from './_generated/server';
type RWCtx = MutationCtx | QueryCtx; type RWCtx = MutationCtx | QueryCtx;
// CHANGED: typed helpers // CHANGED: typed helpers
const ensureUser = async ( const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
ctx: RWCtx,
userId: Id<'users'>,
) => {
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
return user; return user;
}; };
const latestStatusForOwner = async ( const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
ctx: RWCtx,
ownerId: Id<'users'>,
) => {
const [latest] = await ctx.db const [latest] = await ctx.db
.query('statuses') .query('statuses')
.withIndex('by_user_updatedAt', q => q.eq('userId', ownerId)) .withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
.order('desc') .order('desc')
.take(1); .take(1);
return latest as Doc<'statuses'> | null; return latest as Doc<'statuses'> | null;
@@ -180,7 +174,7 @@ export const listHistoryByUser = query({
return await ctx.db return await ctx.db
.query('statuses') .query('statuses')
.withIndex('by_user_updatedAt', q => q.eq('userId', userId)) .withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
.order('desc') .order('desc')
.paginate(paginationOpts); .paginate(paginationOpts);
}, },

View File

@@ -21,8 +21,10 @@
"@convex-dev/auth": "^0.0.81", "@convex-dev/auth": "^0.0.81",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@radix-ui/react-avatar": "^1.1.10", "@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-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
@@ -43,6 +45,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.42.0", "typescript-eslint": "^8.42.0",
"vaul": "^1.1.2",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -7,7 +7,7 @@ import {
ProfileHeader, ProfileHeader,
ResetPasswordForm, ResetPasswordForm,
SignOutForm, SignOutForm,
UserInfoForm UserInfoForm,
} from '@/components/layout/profile'; } from '@/components/layout/profile';
const Profile = async () => { const Profile = async () => {

View File

@@ -29,40 +29,46 @@ const signInFormSchema = z.object({
email: z.email({ email: z.email({
message: 'Please enter a valid email address.', message: 'Please enter a valid email address.',
}), }),
password: z.string() password: z
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, { .string()
message: 'Incorrect password. Does not meet requirements.' .regex(
}) /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/,
{
message: 'Incorrect password. Does not meet requirements.',
},
),
}); });
const signUpFormSchema = z.object({ const signUpFormSchema = z
name: z.string().min(2, { .object({
message: 'Name must be at least 2 characters.', 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.'
}), }),
confirmPassword: z.string().min(8, { email: z.email({
message: 'Password must be at least 8 characters.', message: 'Please enter a valid email address.',
}), }),
}) password: z
.refine((data) => data.password === data.confirmPassword, { .string()
message: 'Passwords do not match!', .min(8, { message: 'Password must be at least 8 characters.' })
path: ['confirmPassword'], .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() { export default function SignIn() {
const { signIn } = useAuthActions(); const { signIn } = useAuthActions();

View File

@@ -1,12 +1,21 @@
'use client'; 'use server';
import { preloadQuery } from 'convex/nextjs';
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
import { api } from '~/convex/_generated/api'; 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 Link from 'next/link';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
const Home = () => { const Home = async () => {
return <main></main>; const preloadedUser = await preloadQuery(api.auth.getUser);
const preloadedStatuses = await preloadQuery(api.statuses.getCurrentForAll)
return (
<main>
<StatusList
preloadedUser={preloadedUser}
preloadedStatuses={preloadedStatuses}
/>
</main>
);
}; };
export default Home; export default Home;

View File

@@ -27,11 +27,7 @@ export const AvatarDropdown = () => {
); );
if (isLoading) if (isLoading)
return ( return <BasedAvatar className='animate-pulse lg:h-10 lg:w-10' />;
<BasedAvatar
className='animate-pulse lg:h-10 lg:w-10'
/>
);
if (!isAuthenticated) return <div />; if (!isAuthenticated) return <div />;
return ( return (

View File

@@ -47,10 +47,12 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
body: file, body: file,
}); });
if (!result.ok) { if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.') const msg = await result.text().catch(() => 'Upload failed.');
throw new Error(msg); 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 }); await updateUserImage({ storageId: uploadResponse.storageId });
toast.success('Profile picture updated.'); toast.success('Profile picture updated.');
} catch (error) { } catch (error) {

View File

@@ -22,34 +22,42 @@ import {
} from '@/components/ui'; } from '@/components/ui';
import { toast } from 'sonner'; import { toast } from 'sonner';
const formSchema = z.object({ const formSchema = z
currentPassword: z.string() .object({
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, { currentPassword: z
message: 'Incorrect current password. Does not meet requirements.', .string()
}), .regex(
newPassword: z.string() /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/,
.min(8, { message: 'New password must be at least 8 characters.' }) {
.max(100, { message: 'New password must be less than 100 characters.' }) message: 'Incorrect current password. Does not meet requirements.',
.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.' newPassword: z
}) .string()
.regex(/[A-Z]/, { .min(8, { message: 'New password must be at least 8 characters.' })
message: 'New password must contain at least one uppercase letter.' .max(100, { message: 'New password must be less than 100 characters.' })
}) .regex(/[0-9]/, {
.regex(/[@#$%^&+=]/, { message: 'New password must contain at least one digit.',
message: 'New password must contain at least one special character.' })
}), .regex(/[a-z]/, {
confirmPassword: z.string(), message: 'New password must contain at least one lowercase letter.',
}) })
.refine((data) => data.currentPassword !== data.newPassword, { .regex(/[A-Z]/, {
message: 'New password must be different from current password.', message: 'New password must contain at least one uppercase letter.',
path: ['newPassword'], })
}) .regex(/[@#$%^&+=]/, {
.refine((data) => data.newPassword === data.confirmPassword, { message: 'New password must contain at least one special character.',
message: 'Passwords do not match.', }),
path: ['confirmPassword'], 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 = () => { export const ResetPasswordForm = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -80,7 +88,7 @@ export const ResetPasswordForm = () => {
console.error('Error updating password:', error); console.error('Error updating password:', error);
toast.error('Error updating password.'); toast.error('Error updating password.');
} finally { } finally {
setLoading(false) setLoading(false);
} }
}; };
@@ -161,4 +169,3 @@ export const ResetPasswordForm = () => {
</> </>
); );
}; };

View File

@@ -1,10 +1,7 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { import { CardHeader, SubmitButton } from '@/components/ui';
CardHeader,
SubmitButton,
} from '@/components/ui';
export const SignOutForm = () => { export const SignOutForm = () => {
const { signOut } = useAuthActions(); const { signOut } = useAuthActions();
@@ -22,4 +19,4 @@ export const SignOutForm = () => {
</SubmitButton> </SubmitButton>
</div> </div>
); );
} };

View File

@@ -1,10 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
type Preloaded,
usePreloadedQuery,
useMutation,
} from 'convex/react';
import { api } from '~/convex/_generated/api'; import { api } from '~/convex/_generated/api';
import { z } from 'zod'; import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -24,16 +20,17 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
const formSchema = z.object({ const formSchema = z.object({
name: z.string() name: z
.trim() .string()
.min(5, { .trim()
message: 'Full name is required & must be at least 5 characters.' .min(5, {
}) message: 'Full name is required & must be at least 5 characters.',
.max(50, { })
message: 'Full name must be less than 50 characters.' .max(50, {
}), message: 'Full name must be less than 50 characters.',
}),
email: z.email({ 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: { defaultValues: {
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
} },
}); });
const handleSubmit = async (values: z.infer<typeof formSchema>) => { const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = []; const ops: Promise<unknown>[] = [];
const name = values.name.trim(); const name = values.name.trim();
const email = values.email.trim().toLowerCase(); const email = values.email.trim().toLowerCase();
if (name !== (user?.name ?? '')) if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
ops.push(updateUserName({name})); if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
if (email !== (user?.email ?? ''))
ops.push(updateUserEmail({email}));
if (ops.length === 0) return; if (ops.length === 0) return;
setLoading(true); setLoading(true);
try { try {
await Promise.all(ops); await Promise.all(ops);
form.reset({ name, email}); form.reset({ name, email });
toast.success('Profile updated successfully.'); toast.success('Profile updated successfully.');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error('Error updating profile.') toast.error('Error updating profile.');
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -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<typeof api.auth.getUser>;
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
};
export const StatusList = ({
preloadedUser,
preloadedStatuses,
}: StatusListProps) => {
const user = usePreloadedQuery(preloadedUser);
const statuses = usePreloadedQuery(preloadedStatuses);
const { tvMode } = useTVMode();
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
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 (
<div className={containerCn}>
<div className={headerCn}>
<div className='flex items-center gap-4'>
<Button
onClick={handleSelectAllClick}
variant={'outline'}
size={'sm'}
className='flex items-center gap2'
>
<CheckCircle2
className={selectAllIconCn}
/>
{selectAll ? 'Unselect All' : 'Select All'}
</Button>
{!tvMode && (
<div className='flex items-center gap-2 text-xs'>
<span className='text-muted-foreground'>Miss the old table?</span>
<Link
href='/table'
className='font-medium hover:underline'
>
Find it here!
</Link>
</div>
)}
</div>
</div>
<div className={cardContainerCn}>
</div>
</div>
);
};

View File

@@ -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<typeof ProgressPrimitive.Root> & {
/** 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<number>(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 (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (progress ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
};
export { BasedProgress };

View File

@@ -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<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -1,5 +1,6 @@
export { Avatar, AvatarImage, AvatarFallback } from './avatar'; export { Avatar, AvatarImage, AvatarFallback } from './avatar';
export { BasedAvatar } from './based-avatar'; export { BasedAvatar } from './based-avatar';
export { BasedProgress } from './based-progress';
export { Button, buttonVariants } from './button'; export { Button, buttonVariants } from './button';
export { export {
Card, Card,
@@ -10,6 +11,18 @@ export {
CardDescription, CardDescription,
CardContent, CardContent,
} from './card'; } from './card';
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
} from './drawer';
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -30,6 +43,7 @@ export {
} from './form'; } from './form';
export { Input } from './input'; export { Input } from './input';
export { Label } from './label'; export { Label } from './label';
export { Progress } from './progress';
export { Separator } from './separator'; export { Separator } from './separator';
export { StatusMessage } from './status-message'; export { StatusMessage } from './status-message';
export { SubmitButton } from './submit-button'; export { SubmitButton } from './submit-button';

View File

@@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -18,3 +18,19 @@ export const ccn = ({
}) => { }) => {
return twMerge(className, context ? on : off); 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}`;
};