start on statuses table list thing
This commit is contained in:
11
bun.lock
11
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=="],
|
||||
|
@@ -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 };
|
||||
},
|
||||
})
|
||||
});
|
||||
|
@@ -17,8 +17,8 @@ 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(),
|
||||
|
@@ -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);
|
||||
},
|
||||
|
@@ -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": {
|
||||
|
@@ -7,7 +7,7 @@ import {
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
SignOutForm,
|
||||
UserInfoForm
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/profile';
|
||||
|
||||
const Profile = async () => {
|
||||
|
@@ -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({
|
||||
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()
|
||||
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.'
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'Password must contain at least one uppercase letter.'
|
||||
message: 'Password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[@#$%^&+=]/, {
|
||||
message: 'Password must contain at least one special character.'
|
||||
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, {
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match!',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
});
|
||||
|
||||
export default function SignIn() {
|
||||
const { signIn } = useAuthActions();
|
||||
|
@@ -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 <main></main>;
|
||||
const Home = async () => {
|
||||
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;
|
||||
|
@@ -27,11 +27,7 @@ export const AvatarDropdown = () => {
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<BasedAvatar
|
||||
className='animate-pulse lg:h-10 lg:w-10'
|
||||
/>
|
||||
);
|
||||
return <BasedAvatar className='animate-pulse lg:h-10 lg:w-10' />;
|
||||
if (!isAuthenticated) return <div />;
|
||||
|
||||
return (
|
||||
|
@@ -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) {
|
||||
|
@@ -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}$/, {
|
||||
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()
|
||||
},
|
||||
),
|
||||
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(/[0-9]/, {
|
||||
message: 'New password must contain at least one digit.',
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: 'New password must contain at least one lowercase letter.'
|
||||
message: 'New password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'New password must contain at least one uppercase letter.'
|
||||
message: 'New password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[@#$%^&+=]/, {
|
||||
message: 'New password must contain at least one special character.'
|
||||
message: 'New password must contain at least one special character.',
|
||||
}),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: 'New password must be different from current password.',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
})
|
||||
.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 = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -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 = () => {
|
||||
</SubmitButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -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()
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, {
|
||||
message: 'Full name is required & must be at least 5 characters.'
|
||||
message: 'Full name is required & must be at least 5 characters.',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Full name must be less than 50 characters.'
|
||||
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<typeof formSchema>) => {
|
||||
const ops: Promise<unknown>[] = [];
|
||||
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);
|
||||
}
|
||||
|
109
src/components/layout/status/list/index.tsx
Normal file
109
src/components/layout/status/list/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
51
src/components/ui/based-progress.tsx
Normal file
51
src/components/ui/based-progress.tsx
Normal 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 };
|
135
src/components/ui/drawer.tsx
Normal file
135
src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
@@ -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';
|
||||
|
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal 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 };
|
@@ -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}`;
|
||||
};
|
||||
|
Reference in New Issue
Block a user