feat(practice-sheets): add card-based ui for practice sheets

This commit is contained in:
shafin-r
2026-01-11 21:02:35 +06:00
parent c4c300da98
commit 16dffe6ffd
7 changed files with 332 additions and 4 deletions

View File

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

19
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-tabs': '@radix-ui/react-tabs':
specifier: ^1.1.13 specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -493,6 +496,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-tabs@1.1.13': '@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies: peerDependencies:
@ -1864,6 +1876,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.7 '@types/react': 19.2.7
'@radix-ui/react-slot@1.2.4(@types/react@19.2.7)(react@19.2.3)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { import {
Tabs, Tabs,
TabsTrigger, TabsTrigger,
@ -6,12 +7,52 @@ import {
} from "../../components/ui/tabs"; } from "../../components/ui/tabs";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { api } from "../../utils/api";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
export const Home = () => { export const Home = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [practiceSheets, setPracticeSheets] = useState([]);
// const logout = useAuthStore((state) => state.logout); // const logout = useAuthStore((state) => state.logout);
// const navigate = useNavigate(); // const navigate = useNavigate();
useEffect(() => {
const fetchPracticeSheets = async () => {
if (!user) return;
try {
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) {
console.error("authStorage not found in local storage");
return;
}
const {
state: { token },
} = JSON.parse(authStorage);
if (!token) {
console.error("Token not found in authStorage");
return;
}
const sheets = await api.getPracticeSheets(token, 1, 10);
setPracticeSheets(sheets.data);
} catch (error) {
console.error("Error fetching practice sheets:", error);
}
};
fetchPracticeSheets();
}, [user]);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<main className="flex flex-col items-center justify-center gap-12 max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="flex flex-col items-center justify-center gap-12 max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
@ -29,7 +70,7 @@ export const Home = () => {
</div> </div>
</div> </div>
<div> <div>
<Tabs defaultValue="all" className="w-full"> <Tabs defaultValue="all" className="w-full space-y-4">
<TabsList className="bg-transparent border-b rounded-none p-0"> <TabsList className="bg-transparent border-b rounded-none p-0">
<TabsTrigger <TabsTrigger
value="all" value="all"
@ -60,7 +101,42 @@ export const Home = () => {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="all"> <TabsContent value="all">
<h1>All Status</h1> <div className="space-y-6">
{practiceSheets.map((sheet) => (
<Card key={sheet?.id}>
<CardHeader>
<CardTitle className="font-satoshi-medium text-xl">
{sheet?.title}
</CardTitle>
<CardDescription className="font-satoshi">
{sheet?.subject}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-between">
<p className="font-satoshi text-gray-700">Not Started</p>
<Badge
variant="secondary"
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide "
>
{sheet?.modules_count} modules
</Badge>
</CardContent>
<CardContent>
<p className="font-satoshi text-gray-700">
{sheet?.time_limit} minutes
</p>
</CardContent>
<CardFooter>
<Button
variant="outline"
className="font-satoshi w-full text-lg py-6 bg-indigo-600 text-white"
>
Start
</Button>
</CardFooter>
</Card>
))}
</div>
</TabsContent> </TabsContent>
<TabsContent value="not-started"> <TabsContent value="not-started">
<h1>Not Started</h1> <h1>Not Started</h1>

View File

@ -27,6 +27,25 @@ export interface ApiError {
message?: string; message?: string;
} }
export interface PracticeSheet {
title: string;
subject: string;
difficulty: string;
time_limit: number;
description: string;
topics: string[];
is_locked: boolean;
id: string;
created_at: string;
created_by: {
id: string;
name: string;
email: string;
};
modules: string[];
modules_count: number;
}
class ApiClient { class ApiClient {
private baseURL: string; private baseURL: string;
@ -92,10 +111,23 @@ class ApiClient {
// Example: Get user profile (authenticated endpoint) // Example: Get user profile (authenticated endpoint)
async getUserProfile(token: string): Promise<User> { async getUserProfile(token: string): Promise<User> {
return this.authenticatedRequest<User>("/auth/me", token); return this.authenticatedRequest<User>("/auth/me/", token);
} }
// Add more API methods here as needed async getPracticeSheets(
token: string,
page: number,
limit: number
): Promise<any> {
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
}).toString();
return this.authenticatedRequest<any>(
`/practice-sheets/?${queryParams}`,
token
);
}
} }
export const api = new ApiClient(API_URL); export const api = new ApiClient(API_URL);