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",
"@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=="],

View File

@@ -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 };
},
});
@@ -101,7 +100,7 @@ export const updateUserPassword = action({
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 };
},
})
});

View File

@@ -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(),

View File

@@ -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);
},

View File

@@ -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": {

View File

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

View File

@@ -29,31 +29,37 @@ 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.',

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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) {

View File

@@ -22,23 +22,31 @@ 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(),
})
@@ -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 = () => {
</>
);
};

View File

@@ -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>
);
}
};

View File

@@ -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,17 +50,15 @@ 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 {
@@ -72,7 +67,7 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
toast.error('Error updating profile.')
toast.error('Error updating profile.');
} finally {
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 { 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';

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);
};
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}`;
};