changed the course cards ui

This commit is contained in:
Rajpreet0 2026-01-14 18:34:08 +01:00
parent b51b3bb751
commit 3482da0635
8 changed files with 362 additions and 40 deletions

140
package-lock.json generated
View file

@ -10,10 +10,12 @@
"dependencies": {
"@prisma/client": "^6.19.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
@ -2934,6 +2936,48 @@
}
}
},
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz",
"integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.3",
"@radix-ui/react-primitive": "2.1.4",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "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"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
"integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
@ -3606,6 +3650,84 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"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-popper": "1.2.8",
"@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"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"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"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@ -4123,6 +4245,24 @@
}
}
},
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",

View file

@ -15,10 +15,12 @@
"dependencies": {
"@prisma/client": "^6.19.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",

View file

@ -0,0 +1,39 @@
"use client";
import { useIsMobile } from "@/src/hooks/use-mobile";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface AbstractTooltipProps {
tooltipText: string;
children: React.ReactNode;
}
const AbstractTooltip: React.FC<AbstractTooltipProps> = ({ tooltipText, children }) => {
const isMobile = useIsMobile();
if (isMobile) {
return (
<Popover>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className="w-auto px-3 py-1 text-sm text-white bg-blue"
>
{tooltipText}
</PopoverContent>
</Popover>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top" align="center">
{tooltipText}
</TooltipContent>
</Tooltip>
);
};
export default AbstractTooltip;

View file

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/src/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-7 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/src/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -46,13 +46,13 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"bg-blue text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="bg-blue fill-blue z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)

View file

@ -6,6 +6,8 @@
* inklusive Teilnehmerzahl, Status und Bearbeitungsoption.
*/
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@/src/components/ui/avatar";
import { Pen, Trash2, Link } from "lucide-react";
import { toast } from "sonner";
@ -20,7 +22,7 @@ interface CourseListItemProps {
timeTo: string; // Uhrzeit am Ende des Kurses
currentParticipants: number; // Aktuelle Anzahl der Teilnehmer
maxParticipants: number; // Maximale Anzahl der Teilnehmer
trainerName: string; // Name(n) des/der Trainer
trainers: string[];
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button
status?: string; // Status des Kurses ("draft" oder "published")
@ -40,7 +42,7 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
timeTo,
currentParticipants,
maxParticipants,
trainerName,
trainers,
onEdit,
onDelete,
status,
@ -59,43 +61,88 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
toast.success("Link wurde in die Zwischenablage kopiert");
};
const getInitials = (name: string) => {
if (!name) return "";
const parts = name.trim().split(" ");
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
return (
<div
className={`bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition flex flex-col gap-4 relative ${
isPast ? "opacity-60 bg-gray-50" : ""
}`}
>
{status && (
<span
className={`w-fit top-3 left-3 text-xs font-semibold px-2 py-1 rounded ${
status === "draft" ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"
}`}
>
{status === "draft" ? "Entwurf" : "Veröffentlicht"}
<div className="flex items-center w-full">
{/*TODO: Avatar */}
{trainers.map((trainer, index) => (
<AbstractTooltip key={trainer} tooltipText={trainer}>
<div
className={`relative ${index !== 0 ? "-ml-3" : ""}`}
style={{ zIndex: trainers.length - index }}
>
<Avatar className="rounded-full border-2 border-white">
<AvatarImage src="https://github.com/shadcn.png" alt={trainer} />
<AvatarFallback>{getInitials(trainer)}</AvatarFallback>
</Avatar>
</div>
</AbstractTooltip>
))}
{/* Time */}
<div className="w-full flex justify-end">
<p className="text-gray-500 text-lg">
{timeFrom} - {timeTo}
</p>
</div>
</div>
{/* Course Name */}
<h2 className="text-xl font-semibold text-gray-900">{courseName}</h2>
<div className="flex items-center justify-between w-full">
{/* Participant Number */}
<span className={isFull ? "text-red-600 font-semibold" : "text-gray-800"}>
{currentParticipants}/{maxParticipants} Teilnehmer
</span>
)}
<div>
<h2 className="text-xl font-semibold text-gray-900">{courseName}</h2>
<p className="text-gray-500">
{room} • {timeFrom} - {timeTo}
</p>
{/*TODO: Price */}
<h2 className="text-lg">15 €</h2>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Teilnehmer</span>
<span className={isFull ? "text-red-600 font-semibold" : "text-gray-800"}>
{currentParticipants}/{maxParticipants}
{/* Participant Bar */}
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue"}`}
style={{ width: `${(currentParticipants / maxParticipants) * 100}%` }}
/>
</div>
<div className="flex items-center justify-between w-full">
{/*TODO: Level Label */}
<span className={`w-fit top-3 left-3 text-xs px-2 py-1 round bg-yellow rounded-lg`}>
Fortgeschritten
</span>
{/* Room */}
<p className="text-gray-500">{room}</p>
</div>
<hr />
<div className="flex items-center justify-between w-full">
{/* Status */}
{status && (
<span
className={`w-fit top-3 left-3 text-xs font-semibold px-2 py-1 rounded ${
status === "draft" ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"
}`}
>
{status === "draft" ? "Entwurf" : "Veröffentlicht"}
</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
style={{ width: `${(currentParticipants / maxParticipants) * 100}%` }}
/>
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-gray-200">
<p className="text-gray-700">{trainerName}</p>
)}
{/* Actions */}
<div className="flex gap-2">
{courseId && status === "published" && (
<button

View file

@ -296,13 +296,6 @@ const CourseListView = () => {
// Get unique rooms for filter (using room names from roomsMap)
const uniqueRooms = Array.from(new Set(courses.map((c) => roomsMap[c.room] || c.room)));
// Get first trainer name (for display)
const getTrainerName = (trainers: string[]) => {
if (trainers.length === 0) return "Kein Trainer";
// For now, using trainer ID as name - in real app, you'd fetch trainer names
return trainers.length > 1 ? `${trainers[0]} +${trainers.length - 1}` : trainers[0];
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
@ -433,7 +426,7 @@ const CourseListView = () => {
timeTo={course.timeTo}
currentParticipants={course._count?.bookings || 0}
maxParticipants={course.maxParticipants}
trainerName={getTrainerName(course.trainers)}
trainers={course.trainers}
status={course.status}
isPast={isPastCourse(course.date)}
showDeleteIcon={deleteMode}