first commit

This commit is contained in:
martin
2026-01-05 16:03:44 +01:00
commit 9805c18dd5
223 changed files with 24265 additions and 0 deletions

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

48
src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { HelmetProvider } from "react-helmet-async";
import { LanguageProvider } from "@/contexts/LanguageContext";
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import Main from "./components/app/Main";
import LoginContainer from "./components/auth/LoginContainer";
import ArtistCollabForm from "@/features/event/components/ArtistCollabForm";
const queryClient = new QueryClient();
const AppContent = () => {
const { user } = useAuth();
return (
<Routes>
<Route path="/collab/:token" element={<ArtistCollabForm />} />
<Route
path="/*"
element={user ? <Main /> : <LoginContainer />}
/>
</Routes>
);
};
const App = () => (
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<LanguageProvider>
<AuthProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<AppContent />
</BrowserRouter>
</AuthProvider>
</LanguageProvider>
</TooltipProvider>
</QueryClientProvider>
</HelmetProvider>
);
export default App;

108
src/components/app/Main.tsx Normal file
View File

@@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { LogOut, Menu } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import DashboardContainer from '../dashboard/DashboardContainer';
import { cn } from '@/lib/utils';
const Main: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [hasActiveEvent, setHasActiveEvent] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Check for active events - This will be replaced with actual API call
useEffect(() => {
// TODO: Replace with actual API call to check for active events
const checkActiveEvents = async () => {
try {
// const response = await fetch('/api/events/active');
// const data = await response.json();
// setHasActiveEvent(data.hasActiveEvent);
// For now, we'll set it to false
setHasActiveEvent(false);
} catch (error) {
console.error('Error checking active events:', error);
}
};
checkActiveEvents();
}, []);
const handleAction = (action: string) => {
console.log('Selected action:', action);
// Handle navigation or other actions based on the selected card
switch (action) {
case 'event':
// Navigate to events page or open event form
break;
case 'blog':
// Navigate to blog page or open blog form
break;
case 'quick-post':
// Open quick post dialog
break;
default:
break;
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => navigate('/')}
>
<img
src="/Logo.png"
alt="Logo"
className="h-10 w-auto object-contain"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden md:block text-sm text-muted-foreground">
Hallo, {user?.name?.split(' ')[0] || 'Benutzer'}
</div>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-sm"
onClick={logout}
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Abmelden</span>
</Button>
</div>
</div>
</header>
{/* Main Content */}
<main className="container py-6 px-0 md:px-4">
<div className="mx-auto md:max-w-5xl">
<DashboardContainer
onActionClick={handleAction}
hasActiveEvent={hasActiveEvent}
/>
</div>
</main>
</div>
);
};
export default Main;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import LoginForm from '@/components/auth/LoginForm';
import { useToast } from '@/hooks/use-toast';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
const LoginContainer: React.FC = () => {
const { toast } = useToast();
const navigate = useNavigate();
const { login, isLoading } = useAuth();
const handleSubmit = async (email: string, password: string) => {
if (!email || !password) {
toast({
title: "Error",
description: "Please fill in all fields",
variant: "destructive",
});
return;
}
try {
await login(email, password);
navigate('/dashboard');
} catch (err: any) {
toast({
title: "Login fehlgeschlagen",
description: err?.message || "Unbekannter Fehler",
variant: "destructive",
});
}
};
return (
<LoginForm
onSubmit={handleSubmit}
isLoading={isLoading}
defaultEmail={(import.meta as any).env.VITE_LOGIN_EMAIL as string}
defaultPassword={(import.meta as any).env.VITE_LOGIN_PASSWORD as string}
/>
);
};
export default LoginContainer;

View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
interface LoginFormProps {
onSubmit?: (email: string, password: string) => Promise<void> | void;
isLoading?: boolean;
defaultEmail?: string;
defaultPassword?: string;
}
const LoginForm: React.FC<LoginFormProps> = ({ onSubmit, isLoading: externalLoading, defaultEmail, defaultPassword }) => {
const [email, setEmail] = useState(defaultEmail ?? '');
const [password, setPassword] = useState(defaultPassword ?? '');
const [isLoading, setIsLoading] = useState(externalLoading ?? false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await onSubmit?.(email, password);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/20 to-secondary/20">
<Card className="w-full max-w-md">
<CardHeader>
<img src="/Logo.png" alt="Logo" className="mx-auto mb-2 h-24 w-40 object-contain" />
<CardTitle className="text-2xl font-bold text-center">Login Erforderlich</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="bitte E-Mail Adresse eingeben"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="bitte Passwort eingeben"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Weiter"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default LoginForm;

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { type Event } from '@/features/event/api/eventService';
import { type DashboardModule, type DashboardModuleContext } from '@/components/dashboard/dashboardModuleTypes';
import { eventDashboardModule } from '@/features/event/dashboardModule';
import { quickPostDashboardModule } from '@/features/photopost/dashboardModule';
interface DashboardCardProps {
title: string;
description: string;
onClick: () => void;
illustration?: string;
}
const DashboardCard: React.FC<DashboardCardProps> = ({ title, description, onClick, illustration }) => (
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full relative overflow-hidden" onClick={onClick}>
<div className="pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full bg-gradient-to-br from-primary/25 via-primary/10 to-transparent blur-3xl" />
{illustration && (
<div
className="hidden md:block pointer-events-none absolute inset-0"
style={{
backgroundImage: `url(${illustration})`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right bottom',
backgroundSize: 'auto 80%',
opacity: 0.9,
filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.15))',
}}
/>
)}
<CardHeader className="pb-2 pr-6 relative z-10">
<div className="flex items-center">
<CardTitle className="text-lg">{title}</CardTitle>
</div>
<CardDescription className="text-sm">{description}</CardDescription>
</CardHeader>
{illustration && (
<div className="md:hidden px-6 pb-4 relative z-10">
<img src={illustration} alt="" className="mt-2 w-full h-auto object-contain" />
</div>
)}
</Card>
);
interface DashboardProps {
onActionClick: (action: string) => void;
hasActiveEvent: boolean;
currentEvent: Event | null;
}
const Dashboard: React.FC<DashboardProps> = ({ onActionClick, hasActiveEvent, currentEvent }) => {
const [activeModuleId, setActiveModuleId] = useState<string | null>(null);
const ctx: DashboardModuleContext = {
currentEvent,
};
const allModules: DashboardModule[] = [
eventDashboardModule,
quickPostDashboardModule,
];
const visibleModules = allModules.filter((m) => m.isVisible ? m.isVisible(ctx) : true);
const activeModule = activeModuleId
? visibleModules.find((m) => m.id === activeModuleId) || null
: null;
const handleBackToOverview = () => {
setActiveModuleId(null);
};
const renderContent = () => {
if (activeModule) {
return activeModule.renderWizard(ctx, handleBackToOverview);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Willkommen zurück</h1>
<p className="text-muted-foreground">Was möchtest du heute tun?</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2">
{visibleModules.map((module) => (
<DashboardCard
key={module.id}
title={module.getTitle(ctx)}
description={module.getDescription(ctx)}
onClick={() => setActiveModuleId(module.id)}
illustration={module.getIllustration?.(ctx)}
/>
))}
</div>
</div>
);
};
return <div className="p-6">{renderContent()}</div>;
};
const DashboardComponent: React.FC<DashboardProps> = Dashboard;
export default DashboardComponent;
// removed modal-based EventTimelineOverlay in favor of inline EventWizard

View File

@@ -0,0 +1,43 @@
import React, { useEffect, useState } from 'react';
import Dashboard from '@/components/dashboard/Dashboard';
import { useApiFetch } from '@/utils/apiFetch';
import { fetchCurrentEvents, type Event } from '@/features/event/api/eventService';
interface DashboardContainerProps {
onActionClick: (action: string) => void;
hasActiveEvent: boolean;
}
const DashboardContainer: React.FC<DashboardContainerProps> = ({ onActionClick, hasActiveEvent }) => {
const apiFetch = useApiFetch();
const [currentEvent, setCurrentEvent] = useState<Event | null>(null);
useEffect(() => {
let isMounted = true;
(async () => {
try {
const events = await fetchCurrentEvents(apiFetch);
if (!isMounted) return;
const list = Array.isArray(events) ? events : [];
const ev = (list.find((e: any) => e && (e.id ?? e._id ?? e.uuid)) as Event) || null;
setCurrentEvent(ev);
} catch {
if (!isMounted) return;
setCurrentEvent(null);
}
})();
return () => {
isMounted = false;
};
}, [apiFetch]);
return (
<Dashboard
onActionClick={onActionClick}
hasActiveEvent={hasActiveEvent}
currentEvent={currentEvent}
/>
);
};
export default DashboardContainer;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { type Event } from '@/features/event/api/eventService';
export interface DashboardModuleContext {
currentEvent: Event | null;
}
export interface DashboardModule {
id: string;
getTitle: (ctx: DashboardModuleContext) => string;
getDescription: (ctx: DashboardModuleContext) => string;
getIllustration?: (ctx: DashboardModuleContext) => string | undefined;
isVisible?: (ctx: DashboardModuleContext) => boolean;
renderWizard: (ctx: DashboardModuleContext, onBack: () => void) => React.ReactNode;
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,56 @@
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,80 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

363
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,363 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,153 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

176
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,234 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

131
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,131 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
}

View File

@@ -0,0 +1,761 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, toast } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster, toast }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,54 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

127
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };

View File

@@ -0,0 +1,91 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// Check for existing session on mount
const savedUser = localStorage.getItem('auth_user');
if (savedUser) {
setUser(JSON.parse(savedUser));
}
// Optional: Token könnte hier geprüft werden
}, []);
const login = async (email: string, password: string) => {
setIsLoading(true);
try {
console.log('Login url:',`${import.meta.env.VITE_WEBHOOK_URL || ''}/auth/login`);
// JWT-Login: API-Call
const response = await fetch(`${import.meta.env.VITE_WEBHOOK_URL || ''}/auth_login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // <-- wichtig für Cookies!
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login fehlgeschlagen');
}
const data = await response.json();
// Erwartet: { token: '...', user: { ... } }
const token = data.token;
const userData: User = data.user || {
id: '1',
email,
name: email.split('@')[0],
};
if (!token) throw new Error('Kein Token erhalten');
// Speichere Token & User
localStorage.setItem('auth_token', token);
setUser(userData);
localStorage.setItem('auth_user', JSON.stringify(userData));
} catch (error: any) {
throw new Error(error.message || 'Login failed');
} finally {
setIsLoading(false);
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('auth_user');
localStorage.removeItem('auth_token');
};
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,614 @@
import React, { createContext, useState, useContext, ReactNode } from 'react';
// Available languages
export type Language = 'de' | 'en' | 'es';
// Language context type
type LanguageContextType = {
language: Language;
setLanguage: (language: Language) => void;
t: (key: string) => string;
};
// Create language context
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
// Translations
const translations: Record<Language, Record<string, any>> = {
de: {
// Navigation
'nav.home': 'Startseite',
'nav.services': 'Leistungen',
'nav.testimonials': 'Kundenstimmen',
'nav.blog': 'Blog',
'nav.contact': 'Kontakt',
'nav.team': 'Team',
// Hero section
'hero.title': 'KI-gestützte Content-Automatisierung',
'hero.subtitle': 'Revolutioniere deine Content-Strategie mit unserem KI-Assistenten',
'hero.description': 'KImaschine automatisiert deine Website-, Blog- und Social-Media-Inhalte mithilfe fortschrittlicher KI. Spare Zeit, steigere die Effizienz und bleibe im digitalen Wettbewerb vorne.',
'hero.cta': 'Starte jetzt',
'hero.secondary': 'Mehr erfahren',
// Hero trust badges
'hero.badge.technology': 'AI-Technologie',
'hero.badge.madeIn': 'Made in Germany',
'hero.badge.saving': '80%',
'hero.badge.savingText': 'Zeitersparnis',
'hero.badge.branding': 'AI Branding',
'hero.badge.brandingText': 'Tailored to your brand',
'hero.badge.gdpr': 'DSGVO',
'hero.badge.gdprText': 'Konform',
// Features section
'features.title': 'Warum KImaschine?',
'features.subtitle': 'Unsere Vorteile',
'features.unified.title': 'Einheitliche Plattform',
'features.unified.description': 'Eine zentrale Lösung für alle deine Content-Kanäle',
'features.ai.title': 'KI-gesteuerte Erstellung',
'features.ai.description': 'Hochwertige Inhalte durch fortschrittliche Künstliche Intelligenz',
'features.multichannel.title': 'Kanalübergreifend',
'features.multichannel.description': 'Nahtlose Veröffentlichung auf Website, Blog, LinkedIn, Instagram und mehr',
'features.time.title': 'Zeitersparnis',
'features.time.description': 'Automatisierung spart bis zu 80% deiner Content-Erstellungszeit',
// Services section
'services.title': 'Unsere Leistungen',
'services.subtitle': 'Was wir bieten',
'services.intro1': 'Wir entwerfen KI mit Wirkung.',
'services.intro2': 'Als KI Architecture Studio entwickeln wir intelligente Assistenzsysteme, die Marketing- und Vertriebsprozesse nicht nur automatisieren, sondern strategisch verstärken.',
'services.focus': 'Unsere Schwerpunkte:',
'services.architecture.title': 'Individuelle KI-Systemarchitektur',
'services.architecture.desc': 'Maßgeschneiderte Lösungen für deinen Marketing- und Sales-Workflow effizient, skalierbar, zukunftsfähig.',
'services.architecture.list1': 'Kundenspezifische Architektur',
'services.architecture.list2': 'Zukunftsfähige Technologien',
'services.architecture.list3': 'Skalierbare Lösungen',
'services.sales.title': 'KI-gestützte Sales-Assistenz',
'services.sales.desc': 'Automatisierung, Lead-Scoring und personalisierte Kundenansprache auf einem neuen Level.',
'services.sales.list1': 'Automatisierte Lead-Qualifizierung',
'services.sales.list2': 'Prädiktives Lead-Scoring',
'services.sales.list3': 'Personalisierte Kundenansprache',
'services.marketing.title': 'Intelligentes Marketing mit KI',
'services.marketing.desc': 'Von Content-Generierung über Kampagnenoptimierung bis zur Zielgruppenerkennung datengetrieben und präzise.',
'services.marketing.list1': 'KI-gestützte Content-Erstellung',
'services.marketing.list2': 'Kampagnenoptimierung in Echtzeit',
'services.marketing.list3': 'Präzise Zielgruppenerkennung',
'services.integration.title': 'Nahtlose Integration in bestehende Tools',
'services.integration.desc': 'Unsere Systeme sind kompatibel mit gängigen Plattformen und passen sich deinem Tech-Stack flexibel an.',
'services.integration.list1': 'API-Integration mit bestehenden Systemen',
'services.integration.list2': 'Flexible Anpassung an deinen Tech-Stack',
'services.integration.list3': 'Nahtlose Workflows',
'services.cta': 'Jetzt anfragen',
// Terms section
'terms.title': 'Allgemeine Geschäftsbedingungen',
'terms.metaTitle': 'AGB | K.I. Maschine',
'terms.metaDescription': 'Allgemeine Geschäftsbedingungen der KImaschine',
'terms.section1.title': '1. Geltungsbereich',
'terms.section1.text': 'Diese Allgemeinen Geschäftsbedingungen (nachfolgend "AGB") gelten für alle Verträge zwischen der KImaschine GmbH (nachfolgend "KImaschine" oder "wir") und ihren Kunden (nachfolgend "Kunde" oder "Sie") über die Nutzung unserer Dienstleistungen und Produkte im Bereich künstlicher Intelligenz.',
'terms.section2.title': '2. Vertragsgegenstand',
'terms.section2.text': 'Gegenstand dieses Vertrags ist die Bereitstellung von KI-basierten Dienstleistungen und Produkten durch KImaschine gemäß der im jeweiligen Angebot beschriebenen Leistungsbeschreibung.',
'terms.section3.title': '3. Vertragsschluss',
'terms.section3.text': 'Die Präsentation unserer Dienstleistungen auf unserer Website stellt kein bindendes Angebot dar. Ein Vertrag kommt erst durch eine schriftliche Auftragsbestätigung oder durch die tatsächliche Erbringung der Leistung zustande.',
'terms.section4.title': '4. Preise und Zahlungsbedingungen',
'terms.section4.text': 'Die Preise für unsere Dienstleistungen entnehmen Sie bitte dem jeweiligen Angebot. Sofern nicht anders vereinbart, sind alle Rechnungen innerhalb von 14 Tagen ab Rechnungsdatum ohne Abzug zur Zahlung fällig.',
'terms.section5.title': '5. Nutzungsrechte',
'terms.section5.text': 'Mit vollständiger Bezahlung der vereinbarten Vergütung erhält der Kunde das nicht-ausschließliche, zeitlich unbeschränkte Recht, die von KImaschine entwickelten KI-Lösungen im vereinbarten Umfang zu nutzen.',
'terms.section6.title': '6. Datenschutz',
'terms.section6.text': 'Wir verarbeiten personenbezogene Daten unserer Kunden gemäß unserer Datenschutzerklärung und unter Beachtung der geltenden datenschutzrechtlichen Bestimmungen.',
'terms.section7.title': '7. Haftung',
'terms.section7.text': 'KImaschine haftet unbeschränkt für Vorsatz und grobe Fahrlässigkeit. Bei leichter Fahrlässigkeit haftet KImaschine nur für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit und für Schäden aus der Verletzung einer wesentlichen Vertragspflicht.',
'terms.section8.title': '8. Schlussbestimmungen',
'terms.section8.text': 'Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Gerichtsstand für alle Streitigkeiten aus dem Vertragsverhältnis ist, soweit gesetzlich zulässig, der Sitz von KImaschine.',
'terms.stand': 'Stand: Mai 2024',
// Team section
'team.title': 'Unser Team',
'team.subtitle': 'Unsere Experten',
'team.position.ceo': 'CEO',
'team.bio.ceo': 'Gründer und Entwickler der K.I. Maschine ',
'team.position.cto': 'CTO',
'team.bio.cto': 'Gründer und Entwickler der K.I. Maschine ',
'team.values.title': 'Unsere Werte',
'team.values.quality.title': 'Qualität',
'team.values.quality.description': 'Wir streben nach höchster Qualität in allem, was wir tun. Verlässlichkeit, Präzision und Exzellenz sind die Grundlagen unserer Arbeit.',
'team.values.innovation.title': 'Innovation',
'team.values.innovation.description': 'Technologischer Fortschritt ist unser Antrieb. Wir denken weiter, um intelligente, zukunftsweisende Lösungen zu schaffen',
'team.values.teamwork.title': 'Teamwork',
'team.values.teamwork.description': 'Gemeinsam erreichen wir mehr. Vertrauen, Respekt und offene Zusammenarbeit bilden das Herzstück unseres Erfolgs.',
// Testimonials section
'testimonials.title': 'Kundenstimmen',
'testimonials.subtitle': 'Was unsere Kunden sagen',
// GDPR
'gdpr.consent': 'Durch die Nutzung dieses Formulars stimmst du unserer Datenschutzerklärung zu.',
'gdpr.policy': 'Datenschutzerklärung',
// Contact form
'contact.title': 'Kontaktiere uns',
'contact.subtitle': 'Wir freuen uns von dir zu hören',
'contact.name': 'Name',
'contact.email': 'E-Mail',
'contact.message': 'Nachricht',
'contact.submit': 'Absenden',
'contact.interest': 'Interesse an',
'contact.website': 'Website-Automatisierung',
'contact.blog': 'Blog-Automatisierung',
'contact.social': 'Social-Media-Automatisierung',
'contact.other': 'Sonstiges',
'contact.budget': 'Budget',
'contact.company': 'Unternehmen',
// Newsletter
'newsletter.title': 'Newsletter anmelden',
'newsletter.description': 'Erhalte die neuesten Updates zu KI und Content-Automatisierung',
'newsletter.placeholder': 'Deine E-Mail-Adresse',
'newsletter.submit': 'Anmelden',
// Footer
'footer.rights': 'Alle Rechte vorbehalten',
'footer.privacy': 'Datenschutz',
'footer.terms': 'AGB',
'footer.imprint': 'Impressum',
// ServicesSection translations
'servicesSection.title': 'Unsere Leistungen',
'servicesSection.intro1': 'Wir entwerfen KI mit Wirkung.',
'servicesSection.intro2': 'Als KI Architecture Studio entwickeln wir intelligente Assistenzsysteme, die Marketing- und Vertriebsprozesse nicht nur automatisieren, sondern strategisch verstärken.',
'servicesSection.details': 'Details',
'servicesSection.cta': 'Alle Leistungen entdecken',
// Architektur
'servicesSection.architecture.title': 'Individuelle KI-Systemarchitektur',
'servicesSection.architecture.description': 'Maßgeschneiderte Lösungen für deinen Marketing- und Sales-Workflow effizient, skalierbar, zukunftsfähig.',
'servicesSection.architecture.feature1': 'Kundenspezifische Architektur',
'servicesSection.architecture.feature2': 'Zukunftsfähige Technologien',
'servicesSection.architecture.feature3': 'Skalierbare Lösungen',
// Sales
'servicesSection.sales.title': 'KI-gestützte Sales-Assistenz',
'servicesSection.sales.description': 'Automatisierung, Lead-Scoring und personalisierte Kundenansprache auf einem neuen Level.',
'servicesSection.sales.feature1': 'Automatisierte Lead-Qualifizierung',
'servicesSection.sales.feature2': 'Prädiktives Lead-Scoring',
'servicesSection.sales.feature3': 'Personalisierte Kundenansprache',
// Marketing
'servicesSection.marketing.title': 'Intelligentes Marketing mit KI',
'servicesSection.marketing.description': 'Von Content-Generierung über Kampagnenoptimierung bis zur Zielgruppenerkennung datengetrieben und präzise.',
'servicesSection.marketing.feature1': 'KI-gestützte Content-Erstellung',
'servicesSection.marketing.feature2': 'Kampagnenoptimierung in Echtzeit',
'servicesSection.marketing.feature3': 'Präzise Zielgruppenerkennung',
// Integration
'servicesSection.integration.title': 'Nahtlose Integration',
'servicesSection.integration.description': 'Unsere Systeme sind kompatibel mit gängigen Plattformen und passen sich deinem Tech-Stack flexibel an.',
'servicesSection.integration.feature1': 'API-Integration',
'servicesSection.integration.feature2': 'Flexible Anpassung',
'servicesSection.integration.feature3': 'Nahtlose Workflows',
'blog.pageTitle': 'Blog',
'blog.metaDescription': 'Aktuelle Informationen zu KI, Content-Automatisierung und digitalen Marketing-Trends',
'blog.title': 'Unser Blog',
'blog.subtitle': 'Entdecken Sie unsere neuesten Gedanken zu KI, Content-Automatisierung und digitalen Marketing-Trends',
'blog.search': 'Suchen',
'blog.searchPlaceholder': 'Suche nach Themen, Tags...',
'blog.searchResultsCount': '{{count}} Ergebnisse gefunden',
'blog.noSearchResults': 'Keine Ergebnisse gefunden. Versuchen Sie es mit einer anderen Suche.',
'blog.noPosts': 'Derzeit sind keine Blogbeiträge verfügbar.',
'blog.minuteRead': 'Min. Lesezeit',
'blog.readMore': 'Weiterlesen',
'blog.backToBlog': 'Zurück zum Blog',
'blog.relatedPosts': 'Das könnte Sie auch interessieren',
'blog.postNotFound': 'Der gesuchte Blogbeitrag wurde nicht gefunden.',
'blog.filterByTag': 'Nach Schlagwort filtern',
},
en: {
// Navigation
'nav.home': 'Home',
'nav.services': 'Services',
'nav.testimonials': 'Testimonials',
'nav.blog': 'Blog',
'nav.contact': 'Contact',
'nav.team': 'Team',
// Hero section
'hero.title': 'AI-powered Content Automation',
'hero.subtitle': 'Revolutionize your content strategy with our AI assistant',
'hero.description': 'KImaschine automates your website, blog, and social media content using advanced AI. Save time, increase efficiency, and stay ahead in the digital competition.',
'hero.cta': 'Get Started',
'hero.secondary': 'Learn More',
// Hero trust badges
'hero.badge.technology': 'AI Technology',
'hero.badge.madeIn': 'Made in Germany',
'hero.badge.saving': '80%',
'hero.badge.savingText': 'Time saving',
'hero.badge.branding': 'AI Branding',
'hero.badge.brandingText': 'Tailored to your brand',
'hero.badge.gdpr': 'GDPR',
'hero.badge.gdprText': 'Compliant',
// Features section
'features.title': 'Why KImaschine?',
'features.subtitle': 'Our advantages',
'features.unified.title': 'Unified Platform',
'features.unified.description': 'One central solution for all your content channels',
'features.ai.title': 'AI-Driven Creation',
'features.ai.description': 'High-quality content through advanced artificial intelligence',
'features.multichannel.title': 'Cross-Channel',
'features.multichannel.description': 'Seamless publishing on website, blog, LinkedIn, Instagram and more',
'features.time.title': 'Time Savings',
'features.time.description': 'Automation saves up to 80% of your content creation time',
// Services section
'services.title': 'Our Services',
'services.subtitle': 'What we offer',
'services.intro1': 'We design AI with impact.',
'services.intro2': 'As an AI architecture studio, we develop intelligent assistant systems that not only automate marketing and sales processes but also strategically enhance them.',
'services.focus': 'Our focus areas:',
'services.architecture.title': 'Custom AI System Architecture',
'services.architecture.desc': 'Tailored solutions for your marketing and sales workflow efficient, scalable, future-proof.',
'services.architecture.list1': 'Custom architecture',
'services.architecture.list2': 'Future-proof technologies',
'services.architecture.list3': 'Scalable solutions',
'services.sales.title': 'AI-powered Sales Assistance',
'services.sales.desc': 'Automation, lead scoring, and personalized customer engagement at a new level.',
'services.sales.list1': 'Automated lead qualification',
'services.sales.list2': 'Predictive lead scoring',
'services.sales.list3': 'Personalized customer engagement',
'services.marketing.title': 'Smart Marketing with AI',
'services.marketing.desc': 'From content generation to campaign optimization and audience targeting data-driven and precise.',
'services.marketing.list1': 'AI-powered content creation',
'services.marketing.list2': 'Real-time campaign optimization',
'services.marketing.list3': 'Precise audience targeting',
'services.integration.title': 'Seamless Integration with Existing Tools',
'services.integration.desc': 'Our systems are compatible with common platforms and flexibly adapt to your tech stack.',
'services.integration.list1': 'API integration with existing systems',
'services.integration.list2': 'Flexible adaptation to your tech stack',
'services.integration.list3': 'Seamless workflows',
'services.cta': 'Request now',
// Terms section
'terms.title': 'Terms and Conditions',
'terms.metaTitle': 'Terms | K.I. Maschine',
'terms.metaDescription': 'Terms and conditions of KImaschine',
'terms.section1.title': '1. Scope',
'terms.section1.text': 'These terms and conditions (hereinafter "Terms") apply to all contracts between KImaschine GmbH (hereinafter "KImaschine" or "we") and its customers (hereinafter "Customer" or "you") regarding the use of our services and products in the field of artificial intelligence.',
'terms.section2.title': '2. Subject Matter',
'terms.section2.text': 'The subject of this contract is the provision of AI-based services and products by KImaschine as described in the respective offer.',
'terms.section3.title': '3. Conclusion of Contract',
'terms.section3.text': 'The presentation of our services on our website does not constitute a binding offer. A contract is only concluded by written order confirmation or by actual performance of the service.',
'terms.section4.title': '4. Prices and Payment Terms',
'terms.section4.text': 'The prices for our services can be found in the respective offer. Unless otherwise agreed, all invoices are payable within 14 days from the invoice date without deduction.',
'terms.section5.title': '5. Rights of Use',
'terms.section5.text': 'Upon full payment of the agreed remuneration, the customer receives the non-exclusive, unlimited right to use the AI solutions developed by KImaschine to the agreed extent.',
'terms.section6.title': '6. Data Protection',
'terms.section6.text': 'We process personal data of our customers in accordance with our privacy policy and in compliance with applicable data protection regulations.',
'terms.section7.title': '7. Liability',
'terms.section7.text': 'KImaschine is liable without limitation for intent and gross negligence. In the case of slight negligence, KImaschine is only liable for damages resulting from injury to life, body or health and for damages resulting from the breach of an essential contractual obligation.',
'terms.section8.title': '8. Final Provisions',
'terms.section8.text': 'The law of the Federal Republic of Germany shall apply to the exclusion of the UN Sales Convention. The place of jurisdiction for all disputes arising from the contractual relationship is, as far as legally permissible, the registered office of KImaschine.',
'terms.stand': 'As of: May 2024',
// Team section
'team.title': 'Our Team',
'team.subtitle': 'Our Experts',
'team.position.ceo': 'CEO',
'team.bio.ceo': 'Founder and developer of the K.I. Maschine',
'team.position.cto': 'CTO',
'team.bio.cto': 'Founder and developer of the K.I. Maschine',
'team.values.title': 'Our Values',
'team.values.quality.title': 'Quality',
'team.values.quality.description': 'We strive for the highest quality in everything we do. Reliability, precision, and excellence form the foundation of our work.',
'team.values.innovation.title': 'Innovation',
'team.values.innovation.description': 'Technological progress drives us. We think ahead to create intelligent, future-ready solutions.',
'team.values.teamwork.title': 'Teamwork',
'team.values.teamwork.description': 'Together we achieve more. Trust, respect, and open collaboration are at the heart of our success.',
'testimonials.title': 'Testimonials',
'testimonials.subtitle': 'What our customers say',
'gdpr.consent': 'By using this form, you agree to our privacy policy.',
'gdpr.policy': 'Privacy Policy',
'contact.title': 'Contact us',
'contact.subtitle': 'We\'d love to hear from you',
'contact.name': 'Name',
'contact.email': 'Email',
'contact.message': 'Message',
'contact.submit': 'Submit',
'contact.interest': 'Interested in',
'contact.website': 'Website Automation',
'contact.blog': 'Blog Automation',
'contact.social': 'Social Media Automation',
'contact.other': 'Other',
'contact.budget': 'Budget',
'contact.company': 'Company',
'newsletter.title': 'Subscribe to newsletter',
'newsletter.description': 'Get the latest updates on AI and content automation',
'newsletter.placeholder': 'Your email address',
'newsletter.submit': 'Subscribe',
'footer.rights': 'All rights reserved',
'footer.privacy': 'Privacy Policy',
'footer.terms': 'Terms of Service',
'footer.imprint': 'Imprint',
'servicesSection.title': 'Our Services',
'servicesSection.intro1': 'We design AI with impact.',
'servicesSection.intro2': 'As an AI architecture studio, we develop intelligent assistant systems that not only automate marketing and sales processes but also strategically enhance them.',
'servicesSection.details': 'Details',
'servicesSection.cta': 'Discover all services',
'servicesSection.architecture.title': 'Custom AI System Architecture',
'servicesSection.architecture.description': 'Tailored solutions for your marketing and sales workflow efficient, scalable, future-proof.',
'servicesSection.architecture.feature1': 'Custom architecture',
'servicesSection.architecture.feature2': 'Future-proof technologies',
'servicesSection.architecture.feature3': 'Scalable solutions',
'servicesSection.sales.title': 'AI-powered Sales Assistance',
'servicesSection.sales.description': 'Automation, lead scoring, and personalized customer engagement at a new level.',
'servicesSection.sales.feature1': 'Automated lead qualification',
'servicesSection.sales.feature2': 'Predictive lead scoring',
'servicesSection.sales.feature3': 'Personalized customer engagement',
'servicesSection.marketing.title': 'Smart Marketing with AI',
'servicesSection.marketing.description': 'From content generation to campaign optimization and audience targeting data-driven and precise.',
'servicesSection.marketing.feature1': 'AI-powered content creation',
'servicesSection.marketing.feature2': 'Real-time campaign optimization',
'servicesSection.marketing.feature3': 'Precise audience targeting',
'servicesSection.integration.title': 'Seamless Integration',
'servicesSection.integration.description': 'Our systems are compatible with common platforms and flexibly adapt to your tech stack.',
'servicesSection.integration.feature1': 'API integration',
'servicesSection.integration.feature2': 'Flexible adaptation',
'servicesSection.integration.feature3': 'Seamless workflows',
'blog.pageTitle': 'Blog',
'blog.metaDescription': 'Latest information on AI, content automation, and digital marketing trends',
'blog.title': 'Our Blog',
'blog.subtitle': 'Explore our latest thoughts on AI, content automation, and digital marketing trends',
'blog.search': 'Search',
'blog.searchPlaceholder': 'Search for topics, tags...',
'blog.searchResultsCount': '{{count}} results found',
'blog.noSearchResults': 'No results found. Try a different search.',
'blog.noPosts': 'No blog posts are currently available.',
'blog.minuteRead': 'min read',
'blog.readMore': 'Read more',
'blog.backToBlog': 'Back to blog',
'blog.relatedPosts': 'You might also like',
'blog.postNotFound': 'The blog post you\'re looking for couldn\'t be found.',
'blog.filterByTag': 'Filter by tag',
},
es: {
// Navigation
'nav.home': 'Inicio',
'nav.services': 'Servicios',
'nav.testimonials': 'Testimonios',
'nav.blog': 'Blog',
'nav.contact': 'Contacto',
'nav.team': 'Equipo',
// Hero section
'hero.title': 'Automatización de contenido impulsada por IA',
'hero.subtitle': 'Revoluciona tu estrategia de contenido con nuestro asistente de IA',
'hero.description': 'KImaschine automatiza el contenido de tu sitio web, blog y redes sociales utilizando IA avanzada. Ahorra tiempo, aumenta la eficiencia y mantente a la vanguardia en la competencia digital.',
'hero.cta': 'Comenzar',
'hero.secondary': 'Saber más',
// Hero trust badges
'hero.badge.technology': 'Tecnología IA',
'hero.badge.madeIn': 'Hecho en Alemania',
'hero.badge.saving': '80%',
'hero.badge.savingText': 'Ahorro de tiempo',
'hero.badge.branding': 'Branding IA',
'hero.badge.brandingText': 'Adaptado a tu marca',
'hero.badge.gdpr': 'RGPD',
'hero.badge.gdprText': 'Conforme',
// Features section
'features.title': '¿Por qué KImaschine?',
'features.subtitle': 'Nuestras ventajas',
'features.unified.title': 'Plataforma unificada',
'features.unified.description': 'Una solución central para todos tus canales de contenido',
'features.ai.title': 'Creación impulsada por IA',
'features.ai.description': 'Contenido de alta calidad a través de inteligencia artificial avanzada',
'features.multichannel.title': 'Multicanal',
'features.multichannel.description': 'Publicación fluida en sitio web, blog, LinkedIn, Instagram y más',
'features.time.title': 'Ahorro de tiempo',
'features.time.description': 'La automatización ahorra hasta el 80% del tiempo de creación de contenido',
// Services section
'services.title': 'Nuestros servicios',
'services.subtitle': 'Lo que ofrecemos',
'services.intro1': 'Diseñamos IA con impacto.',
'services.intro2': 'Como estudio de arquitectura de IA, desarrollamos sistemas inteligentes de asistencia que no solo automatizan los procesos de marketing y ventas, sino que también los refuerzan estratégicamente.',
'services.focus': 'Nuestras áreas de enfoque:',
'services.architecture.title': 'Arquitectura de sistemas de IA personalizada',
'services.architecture.desc': 'Soluciones a medida para tu flujo de trabajo de marketing y ventas: eficientes, escalables y preparadas para el futuro.',
'services.architecture.list1': 'Arquitectura personalizada',
'services.architecture.list2': 'Tecnologías preparadas para el futuro',
'services.architecture.list3': 'Soluciones escalables',
'services.sales.title': 'Asistencia de ventas impulsada por IA',
'services.sales.desc': 'Automatización, puntuación de leads y atención personalizada al cliente a un nuevo nivel.',
'services.sales.list1': 'Cualificación automática de leads',
'services.sales.list2': 'Puntuación predictiva de leads',
'services.sales.list3': 'Atención personalizada al cliente',
'services.marketing.title': 'Marketing inteligente con IA',
'services.marketing.desc': 'Desde la generación de contenidos hasta la optimización de campañas y la identificación de audiencias: basado en datos y preciso.',
'services.marketing.list1': 'Creación de contenidos con IA',
'services.marketing.list2': 'Optimización de campañas en tiempo real',
'services.marketing.list3': 'Identificación precisa de audiencias',
'services.integration.title': 'Integración perfecta en herramientas existentes',
'services.integration.desc': 'Nuestros sistemas son compatibles con las plataformas más comunes y se adaptan de forma flexible a tu stack tecnológico.',
'services.integration.list1': 'Integración API con sistemas existentes',
'services.integration.list2': 'Adaptación flexible a tu stack tecnológico',
'services.integration.list3': 'Flujos de trabajo integrados',
'services.cta': 'Solicitar ahora',
// Terms section
'terms.title': 'Términos y condiciones',
'terms.metaTitle': 'Términos | K.I. Maschine',
'terms.metaDescription': 'Términos y condiciones de KImaschine',
'terms.section1.title': '1. Ámbito de aplicación',
'terms.section1.text': 'Estos términos y condiciones (en adelante, "Términos") se aplican a todos los contratos entre KImaschine GmbH (en adelante, "KImaschine" o "nosotros") y sus clientes (en adelante, "Cliente" o "usted") en relación con el uso de nuestros servicios y productos en el ámbito de la inteligencia artificial.',
'terms.section2.title': '2. Objeto del contrato',
'terms.section2.text': 'El objeto de este contrato es la prestación de servicios y productos basados en IA por parte de KImaschine según lo descrito en la oferta correspondiente.',
'terms.section3.title': '3. Celebración del contrato',
'terms.section3.text': 'La presentación de nuestros servicios en nuestro sitio web no constituye una oferta vinculante. Un contrato solo se concluye mediante confirmación escrita del pedido o mediante la prestación efectiva del servicio.',
'terms.section4.title': '4. Precios y condiciones de pago',
'terms.section4.text': 'Los precios de nuestros servicios se pueden consultar en la oferta correspondiente. Salvo acuerdo en contrario, todas las facturas deberán abonarse en un plazo de 14 días a partir de la fecha de la factura sin deducción.',
'terms.section5.title': '5. Derechos de uso',
'terms.section5.text': 'Tras el pago íntegro de la remuneración acordada, el cliente recibe el derecho no exclusivo e ilimitado de utilizar las soluciones de IA desarrolladas por KImaschine en la medida acordada.',
'terms.section6.title': '6. Protección de datos',
'terms.section6.text': 'Procesamos los datos personales de nuestros clientes de acuerdo con nuestra política de privacidad y cumpliendo con la normativa de protección de datos aplicable.',
'terms.section7.title': '7. Responsabilidad',
'terms.section7.text': 'KImaschine es responsable sin limitación por dolo y negligencia grave. En caso de negligencia leve, KImaschine solo será responsable de los daños derivados de lesiones a la vida, la integridad física o la salud y de los daños derivados del incumplimiento de una obligación contractual esencial.',
'terms.section8.title': '8. Disposiciones finales',
'terms.section8.text': 'Se aplicará la legislación de la República Federal de Alemania con exclusión de la Convención de las Naciones Unidas sobre los Contratos de Compraventa Internacional de Mercaderías. El lugar de jurisdicción para todas las disputas derivadas de la relación contractual será, en la medida en que la ley lo permita, la sede de KImaschine.',
'terms.stand': 'Fecha: mayo de 2024',
// Team section
'team.title': 'Nuestro Equipo',
'team.subtitle': 'Nuestros Expertos',
'team.position.ceo': 'CEO',
'team.bio.ceo': 'Fundador y desarrollador de la K.I. Maschine',
'team.position.cto': 'CTO',
'team.bio.cto': 'Fundador y desarrollador de la K.I. Maschine',
'team.values.title': 'Nuestros Valores',
'team.values.quality.title': 'Calidad',
'team.values.quality.description': 'Nos esforzamos por alcanzar la máxima calidad en todo lo que hacemos. La fiabilidad, la precisión y la excelencia son la base de nuestro trabajo.',
'team.values.innovation.title': 'Innovación',
'team.values.innovation.description': 'El progreso tecnológico nos impulsa. Pensamos en el futuro para crear soluciones inteligentes y preparadas para el mañana.',
'team.values.teamwork.title': 'Trabajo en Equipo',
'team.values.teamwork.description': 'Juntos logramos más. La confianza, el respeto y la colaboración abierta están en el corazón de nuestro éxito.',
'testimonials.title': 'Testimonios',
'testimonials.subtitle': 'Lo que dicen nuestros clientes',
'gdpr.consent': 'Al utilizar este formulario, aceptas nuestra política de privacidad.',
'gdpr.policy': 'Política de privacidad',
'contact.title': 'Contáctanos',
'contact.subtitle': 'Nos encantaría saber de ti',
'contact.name': 'Nombre',
'contact.email': 'Correo electrónico',
'contact.message': 'Mensaje',
'contact.submit': 'Enviar',
'contact.interest': 'Interesado en',
'contact.website': 'Automatización de sitio web',
'contact.blog': 'Automatización de blog',
'contact.social': 'Automatización de redes sociales',
'contact.other': 'Otro',
'contact.budget': 'Presupuesto',
'contact.company': 'Empresa',
'newsletter.title': 'Suscríbete al boletín',
'newsletter.description': 'Recibe las últimas actualizaciones sobre IA y automatización de contenido',
'newsletter.placeholder': 'Tu dirección de correo electrónico',
'newsletter.submit': 'Suscribirse',
'footer.rights': 'Todos los derechos reservados',
'footer.privacy': 'Política de privacidad',
'footer.terms': 'Términos de servicio',
'footer.imprint': 'Aviso legal',
'servicesSection.title': 'Nuestros servicios',
'servicesSection.intro1': 'Diseñamos IA con impacto.',
'servicesSection.intro2': 'Como estudio de arquitectura de IA, desarrollamos sistemas inteligentes de asistencia que no solo automatizan los procesos de marketing y ventas, sino que también los refuerzan estratégicamente.',
'servicesSection.details': 'Detalles',
'servicesSection.cta': 'Descubre todos los servicios',
'servicesSection.architecture.title': 'Arquitectura de sistemas de IA personalizada',
'servicesSection.architecture.description': 'Soluciones a medida para tu flujo de trabajo de marketing y ventas: eficientes, escalables y preparadas para el futuro.',
'servicesSection.architecture.feature1': 'Arquitectura personalizada',
'servicesSection.architecture.feature2': 'Tecnologías preparadas para el futuro',
'servicesSection.architecture.feature3': 'Soluciones escalables',
'servicesSection.sales.title': 'Asistencia de ventas impulsada por IA',
'servicesSection.sales.description': 'Automatización, puntuación de leads y atención personalizada al cliente a un nuevo nivel.',
'servicesSection.sales.feature1': 'Cualificación automática de leads',
'servicesSection.sales.feature2': 'Puntuación predictiva de leads',
'servicesSection.sales.feature3': 'Atención personalizada al cliente',
'servicesSection.marketing.title': 'Marketing inteligente con IA',
'servicesSection.marketing.description': 'Desde la generación de contenidos hasta la optimización de campañas y la identificación de audiencias: basado en datos y preciso.',
'servicesSection.marketing.feature1': 'Creación de contenidos con IA',
'servicesSection.marketing.feature2': 'Optimización de campañas en tiempo real',
'servicesSection.marketing.feature3': 'Identificación precisa de audiencias',
'servicesSection.integration.title': 'Integración perfecta',
'servicesSection.integration.description': 'Nuestros sistemas son compatibles con las plataformas más comunes y se adaptan de forma flexible a tu stack tecnológico.',
'servicesSection.integration.feature1': 'Integración API',
'servicesSection.integration.feature2': 'Adaptación flexible',
'servicesSection.integration.feature3': 'Flujos de trabajo integrados',
'blog.pageTitle': 'Blog',
'blog.metaDescription': 'Información actualizada sobre IA, automatización de contenidos y tendencias de marketing digital',
'blog.title': 'Nuestro Blog',
'blog.subtitle': 'Explore nuestros últimos pensamientos sobre IA, automatización de contenido y tendencias de marketing digital',
'blog.search': 'Buscar',
'blog.searchPlaceholder': 'Buscar temas, etiquetas...',
'blog.searchResultsCount': '{{count}} resultados encontrados',
'blog.noSearchResults': 'No se encontraron resultados. Intente con una búsqueda diferente.',
'blog.noPosts': 'No hay publicaciones de blog disponibles actualmente.',
'blog.minuteRead': 'min de lectura',
'blog.readMore': 'Leer más',
'blog.backToBlog': 'Volver al blog',
'blog.relatedPosts': 'También te puede interesar',
'blog.postNotFound': 'No se pudo encontrar la publicación de blog que estás buscando.',
'blog.filterByTag': 'Filtrar por etiqueta',
}
};
// Language provider component
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
// Default language based on browser
const getBrowserLanguage = (): Language => {
const browserLang = navigator.language.split('-')[0];
if (browserLang === 'de') return 'de';
if (browserLang === 'es') return 'es';
return 'en'; // Default to English
};
const [language, setLanguage] = useState<Language>(() => {
// Try to get language from localStorage first
const storedLanguage = localStorage.getItem('kimaschine-language');
if (storedLanguage === 'de' || storedLanguage === 'en' || storedLanguage === 'es') {
return storedLanguage as Language;
}
// Otherwise use browser language
try {
return getBrowserLanguage();
} catch (e) {
return 'de'; // Fallback to German
}
});
// Update language and store in localStorage
const handleSetLanguage = (newLang: Language) => {
setLanguage(newLang);
localStorage.setItem('kimaschine-language', newLang);
};
// Translation function
const t = (key: string): string => {
return translations[language][key] || key;
};
const value = {
language,
setLanguage: handleSetLanguage,
t,
};
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
};
// Custom hook to use the language context
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
export type LanguageProviderProps = {
children: ReactNode;
};

View File

@@ -0,0 +1,159 @@
// Create a new blog entry and return its id
export const newBlog = async (): Promise<string | null> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const { getWebhookUrl } = await import('@/lib/config');
const response = await apiFetch(`${getWebhookUrl()}/blog/new_blog`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// ggf. weitere Daten je nach API, aktuell leer
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Annahme: neue id ist in data.id oder data.blog_id
return data.id || data.blog_id || null;
} catch (error) {
console.error('Error creating new blog:', error);
return null;
}
};
// Generate a blog post via backend
export interface PublishBlogPostParams {
blogId: string;
publishDate?: string;
publishToSocialMedia: boolean;
socialMediaChannels: string[];
socialMediaLanguages: string[];
}
export const publishBlogPost = async ({
blogId,
publishDate,
publishToSocialMedia,
socialMediaChannels,
socialMediaLanguages
}: PublishBlogPostParams): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const { getWebhookUrl } = await import('@/lib/config');
const response = await apiFetch(`${getWebhookUrl()}/blog/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
blog_id: blogId,
publish_date: publishDate || new Date().toISOString().split('T')[0],
publish_socialmedia: publishToSocialMedia,
socialmedia_channels: socialMediaChannels,
socialmedia_languages: socialMediaLanguages
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error publishing blog post:', error);
throw error;
}
};
export const updateBlogPost = async ({
blogId,
content,
}: {
blogId: string;
content: any;
}): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const { getWebhookUrl } = await import('@/lib/config');
const response = await apiFetch(`${getWebhookUrl()}/blog/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
blog_id: blogId,
content
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating blog post:', error);
throw error;
}
};
export const generateBlogPost = async ({
blog_Id,
topic,
tonality,
languages,
}: {
blog_Id: string;
topic: string;
tonality: string;
languages: string[];
}): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const { getWebhookUrl } = await import('@/lib/config');
const response = await apiFetch(`${getWebhookUrl()}/blog/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
blog_Id,
topic,
tonality,
languages,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error generating blog post:', error);
throw error;
}
};
// Fetch blog topic suggestions from backend
export const fetchBlogTopicSuggestions = async (): Promise<string[] | null> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const { getWebhookUrl } = await import('@/lib/config');
const response = await apiFetch(`${getWebhookUrl()}/blog/topic_suggestion`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const res = await response.json();
const arr = res?.topics || res;
if (Array.isArray(arr)) return arr.slice(0, 4);
return null;
} catch (error) {
console.error('Error fetching blog topic suggestions:', error);
return null;
}
};

View File

View File

@@ -0,0 +1,604 @@
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { uploadBlogFile, generateBlogPostImage } from '@/shared/api/fileService';
import { generateBlogPost, newBlog, updateBlogPost, publishBlogPost } from '@/features/blog/api/blogService';
import { ArrowLeft, Sparkles, Upload, Image as ImageIcon, X, Loader2, Check } from 'lucide-react';
import { fetchBlogTopicSuggestions } from '@/features/blog/api/blogService';
import LanguageTabs from './LanguageTabs';
import SocialSchedulingPanel from './SocialSchedulingPanel';
const tonalities = ['Informativ', 'Inspirierend', 'Unterhaltsam', 'Formell'];
const languages = [
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
];
const socialChannels = ['LinkedIn', 'Twitter', 'Facebook', 'Instagram'];
interface BlogWizardProps {
onComplete: () => void;
onNewBlog?: () => Promise<string>;
onGenerate?: (args: { blog_Id: string; topic: string; tonality: string; languages: string[] }) => Promise<any>;
onUpdate?: (args: { blogId: string; content: any }) => Promise<any>;
onPublish?: (args: { blogId: string; publishDate?: string; publishToSocialMedia: boolean; socialMediaChannels: string[]; socialMediaLanguages: string[] }) => Promise<any>;
onFetchSuggestions?: () => Promise<string[] | null>;
onUploadFile?: (file: File, blogId: string) => Promise<{ success: boolean; url?: string; fileUrl?: string }>;
onGenerateImage?: (blogId: string, title: string) => Promise<{ success: boolean; url?: string; fileUrl?: string }>;
}
type MediaItem = { id: string; url: string; type: 'image' | 'video' };
type WizardStep = 'topic' | 'loading' | 'result';
const BlogWizard: React.FC<BlogWizardProps> = ({ onComplete, onNewBlog, onGenerate, onUpdate, onPublish, onFetchSuggestions, onUploadFile, onGenerateImage }) => {
const [step, setStep] = useState<WizardStep>('topic');
const [topic, setTopic] = useState('');
const [tone, setTone] = useState(tonalities[0]);
const [selectedLangs, setSelectedLangs] = useState<string[]>(languages.map(lang => lang.code));
const [isLoading, setIsLoading] = useState(false);
const [generatedContent, setGeneratedContent] = useState<any>(null);
const [editedContents, setEditedContents] = useState<Record<string, string>>({});
const [initialContents, setInitialContents] = useState<Record<string, string>>({});
const [activeLang, setActiveLang] = useState<string>('');
const [uploadedMedia, setUploadedMedia] = useState<{id: string; url: string; type: 'image' | 'video'}[]>([]);
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [topicSuggestions, setTopicSuggestions] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [blogId, setBlogId] = useState<string>('');
const [showSocial, setShowSocial] = useState<boolean>(false);
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [scheduledTime, setScheduledTime] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const hasChanges = React.useCallback((): boolean => {
if (Object.keys(editedContents).length === 0) return false;
return Object.keys(editedContents).some(
lang => editedContents[lang] !== (initialContents[lang] || '')
);
}, [editedContents, initialContents]);
const handleSaveChanges = React.useCallback(async () => {
if (!hasChanges() || !blogId) return;
setIsSaving(true);
try {
if (onUpdate) {
await onUpdate({ blogId, content: editedContents });
} else {
await updateBlogPost({ blogId, content: editedContents });
}
setInitialContents({ ...editedContents });
} catch (error) {
console.error('Error saving changes:', error);
} finally {
setIsSaving(false);
}
}, [blogId, editedContents, hasChanges]);
const handlePublish = async () => {
try {
if (!blogId) return;
if (hasChanges()) {
await handleSaveChanges();
}
if (onPublish) {
await onPublish({
blogId,
publishDate: scheduledTime || undefined,
publishToSocialMedia: showSocial && selectedChannels.length > 0,
socialMediaChannels: showSocial ? selectedChannels : [],
socialMediaLanguages: Object.keys(editedContents)
});
} else {
await publishBlogPost({
blogId,
publishDate: scheduledTime || undefined,
publishToSocialMedia: showSocial && selectedChannels.length > 0,
socialMediaChannels: showSocial ? selectedChannels : [],
socialMediaLanguages: Object.keys(editedContents)
});
}
onComplete();
} catch (error) {
console.error('Error publishing blog post:', error);
}
};
const handleRemoveImage = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setUploadedMedia(prev => prev.filter(media => media.id !== id));
if (selectedMediaId === id) {
setSelectedMediaId(uploadedMedia.length > 1 ?
uploadedMedia[0].id : null);
}
};
const handleSelectImage = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedMediaId(id);
};
const handleImageClick = (e: React.MouseEvent, url: string) => {
e.stopPropagation();
setPreviewImage(url);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0]) return;
const file = e.target.files[0];
if (!file.type.startsWith('image/')) {
return alert('Nur Bilder erlaubt');
}
try {
let currentBlogId = blogId;
if (!currentBlogId) {
currentBlogId = onNewBlog ? await onNewBlog() : await newBlog();
if (!currentBlogId) return;
setBlogId(currentBlogId);
}
const res = onUploadFile ? await onUploadFile(file, currentBlogId) : await uploadBlogFile(file, currentBlogId);
if (res.success && (res.url || res.fileUrl)) {
const mediaItem = {
id: `file-${Date.now()}`,
url: res.url || res.fileUrl || '',
type: 'image' as const
};
setUploadedMedia(prev => [...prev, mediaItem]);
setSelectedMediaId(mediaItem.id);
}
} catch (error) {
console.error('Error uploading file:', error);
alert('Fehler beim Hochladen der Datei');
}
};
const handleGenerateImage = async () => {
if (!topic.trim()) {
alert('Bitte geben Sie zuerst ein Thema ein');
return;
}
setIsGeneratingImage(true);
try {
let currentBlogId = blogId;
if (!currentBlogId) {
currentBlogId = onNewBlog ? await onNewBlog() : await newBlog();
if (!currentBlogId) return;
setBlogId(currentBlogId);
}
const result = onGenerateImage ? await onGenerateImage(currentBlogId, topic) : await generateBlogPostImage(currentBlogId, topic);
if (result.success && (result.url || result.fileUrl)) {
const mediaItem = {
id: `gen-${Date.now()}`,
url: result.url || result.fileUrl || '',
type: 'image' as const
};
setUploadedMedia(prev => [...prev, mediaItem]);
setSelectedMediaId(mediaItem.id);
}
} catch (error) {
console.error('Error generating image:', error);
alert('Fehler beim Generieren des Bildes');
} finally {
setIsGeneratingImage(false);
}
};
const handleGenerate = async () => {
if (!topic.trim() || selectedLangs.length === 0) {
alert('Bitte füllen Sie alle erforderlichen Felder aus');
return;
}
if (!selectedMediaId) {
alert('Bitte wählen Sie ein Bild aus');
return;
}
setStep('loading');
setIsLoading(true);
try {
const newBlogId = onNewBlog ? await onNewBlog() : await newBlog();
if (!newBlogId) {
throw new Error('Could not create a new blog entry');
}
setBlogId(newBlogId);
const res = onGenerate
? await onGenerate({ blog_Id: newBlogId, topic, tonality: tone, languages: selectedLangs })
: await generateBlogPost({ blog_Id: newBlogId, topic, tonality: tone, languages: selectedLangs });
let blogArr = null;
let gen = res;
if (Array.isArray(res) && res[0]?.blog) {
blogArr = res[0].blog;
gen = res[0];
} else if (res?.blog && Array.isArray(res.blog)) {
blogArr = res.blog;
}
setGeneratedContent(gen);
if (blogArr) {
const contents: Record<string, string> = {};
blogArr.forEach((entry: { language: string; content: string }) => {
contents[entry.language] = entry.content;
});
setEditedContents(contents);
setInitialContents(contents);
setActiveLang(selectedLangs[0] || '');
} else if (typeof gen === 'string') {
setEditedContents({ de: gen });
setInitialContents({ de: gen });
setActiveLang('de');
}
setStep('result');
} catch (error) {
console.error('Error generating blog post:', error);
setStep('topic');
alert('Fehler beim Generieren des Blogbeitrags. Bitte versuchen Sie es erneut.');
} finally {
setIsLoading(false);
}
};
const handleGetSuggestions = async () => {
setIsLoadingSuggestions(true);
try {
const suggestions = onFetchSuggestions
? await onFetchSuggestions()
: await fetchBlogTopicSuggestions();
if (suggestions) {
setTopicSuggestions(suggestions);
}
} catch (error) {
console.error('Error fetching topic suggestions:', error);
} finally {
setIsLoadingSuggestions(false);
}
};
const handleSelectSuggestion = (suggestion: string) => {
setTopic(suggestion);
setTopicSuggestions([]);
};
return (
<>
<Card className="w-full max-w-3xl mx-auto">
<CardHeader>
<CardTitle>
{step === 'result' ? 'Neuen Blogbeitrag erstellen' : 'Blog Assistent'}
</CardTitle>
</CardHeader>
<CardContent>
{step === 'result' ? (
<div className="space-y-6">
<div className="flex justify-between items-center">
<Button
variant="outline"
onClick={() => setStep('topic')}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Zurück
</Button>
</div>
<div className="space-y-4">
<LanguageTabs
languages={languages}
selectedLangs={selectedLangs}
activeLang={activeLang}
onSelect={setActiveLang}
/>
{activeLang ? (
<Textarea
value={editedContents[activeLang] || ''}
onChange={(e) =>
setEditedContents({ ...editedContents, [activeLang]: e.target.value })
}
rows={14}
className="w-full text-sm"
/>
) : (
<p className="text-muted-foreground">Kein Inhalt verfügbar</p>
)}
<div className="flex">
<Button
variant="outline"
onClick={handleSaveChanges}
disabled={!hasChanges() || isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird gespeichert...
</>
) : 'Änderungen speichern'}
</Button>
</div>
</div>
{selectedMediaId && (
<div className="mt-6">
<h3 className="text-lg font-medium mb-2">Ausgewähltes Bild</h3>
<div className="relative w-full max-w-md aspect-video rounded-lg overflow-hidden border">
<img
src={uploadedMedia.find(m => m.id === selectedMediaId)?.url}
alt="Ausgewähltes Blog-Bild"
className="w-full h-full object-cover"
/>
</div>
</div>
)}
</div>
) : step === 'loading' ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Beitrag wird generiert...</p>
</div>
) : (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="topic">Thema des Blogbeitrags</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="text-sm text-muted-foreground"
onClick={handleGetSuggestions}
disabled={isLoadingSuggestions}
>
{isLoadingSuggestions ? (
<>
<span className="h-4 w-4 border-2 border-muted-foreground/50 border-t-primary rounded-full animate-spin mr-2" />
Lade Vorschläge...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-1.5" />
Thema vorschlagen lassen
</>
)}
</Button>
</div>
<div className="relative">
<Input
id="topic"
placeholder="Zum Beispiel: Die besten Tipps für erfolgreiches Social Media Marketing"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
{topicSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border rounded-md shadow-lg">
<div className="p-2 text-sm text-muted-foreground">Themenvorschläge:</div>
{topicSuggestions.map((suggestion, index) => (
<button
key={index}
type="button"
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => handleSelectSuggestion(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Ton</Label>
<Select value={tone} onValueChange={setTone}>
<SelectTrigger>
<SelectValue placeholder="Ton auswählen" />
</SelectTrigger>
<SelectContent>
{tonalities.map((tone) => (
<SelectItem key={tone} value={tone}>
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Sprachen</Label>
<div className="flex items-center space-x-4">
{languages.map((lang) => (
<div key={lang.code} className="flex items-center space-x-1.5">
<input
type="checkbox"
id={`lang-${lang.code}`}
checked={selectedLangs.includes(lang.code)}
onChange={(e) => {
if (e.target.checked) {
setSelectedLangs([...selectedLangs, lang.code]);
} else {
setSelectedLangs(selectedLangs.filter(l => l !== lang.code));
}
}}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Label htmlFor={`lang-${lang.code}`} className="text-sm font-medium leading-none">
{lang.label}
</Label>
</div>
))}
</div>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label>Medien</Label>
</div>
<div className="flex items-center space-x-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Hochladen
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*"
onChange={handleImageUpload}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={handleGenerateImage}
disabled={!topic.trim() || isGeneratingImage}
>
{isGeneratingImage ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
)}
KI-Bild generieren
</Button>
</div>
</div>
{uploadedMedia.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{uploadedMedia.map((media) => {
const isSelected = selectedMediaId === media.id;
return (
<div
key={media.id}
onClick={(e) => handleImageClick(e, media.url)}
className={`relative group rounded-md overflow-hidden border-2 cursor-pointer transition-all ${
isSelected ? 'border-primary ring-2 ring-primary' : 'border-transparent hover:border-muted-foreground/30'
}`}
>
<div className="aspect-square bg-muted/50 flex items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
<img
src={media.url}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div
onClick={(e) => handleSelectImage(media.id, e)}
className={`absolute top-1 right-1 w-5 h-5 rounded flex items-center justify-center transition-colors ${
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-background/90 text-foreground/50 hover:bg-background/80 hover:text-foreground/70'
}`}
>
{isSelected && <Check className="h-3.5 w-3.5" />}
</div>
<button
type="button"
onClick={(e) => handleRemoveImage(media.id, e)}
className="absolute top-1 left-1 p-1 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-end">
<Button
onClick={handleGenerate}
disabled={!topic.trim() || isLoading || !selectedMediaId}
>
{isLoading ? 'Wird generiert...' : 'Beitrag generieren'}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{step === 'result' && (
<SocialSchedulingPanel
showSocial={showSocial}
onToggleSocial={() => setShowSocial((v) => !v)}
socialChannels={socialChannels}
selectedChannels={selectedChannels}
onToggleChannel={(c) => setSelectedChannels((prev) => prev.includes(c) ? prev.filter(ch => ch !== c) : [...prev, c])}
languages={languages}
selectedLangs={selectedLangs}
editedContents={editedContents}
initialContents={initialContents}
activeLang={activeLang}
setActiveLang={setActiveLang}
setEditedContents={setEditedContents}
scheduledTime={scheduledTime}
setScheduledTime={setScheduledTime}
onPublish={handlePublish}
canPublish={!hasChanges()}
/>
)}
{previewImage && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-4xl w-full max-h-[90vh]">
<button
className="absolute -top-10 right-0 text-white hover:text-gray-300"
onClick={(e) => {
e.stopPropagation();
setPreviewImage(null);
}}
>
<X className="w-6 h-6" />
</button>
<div className="bg-background rounded-lg overflow-hidden">
<img
src={previewImage}
alt="Vorschau"
className="w-full h-auto max-h-[80vh] object-contain mx-auto"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</div>
)}
</>
);
};
export default BlogWizard;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Button } from '@/components/ui/button';
export interface Language {
code: string;
label: string;
}
interface LanguageTabsProps {
languages: Language[];
selectedLangs: string[];
activeLang: string;
onSelect: (code: string) => void;
}
const LanguageTabs: React.FC<LanguageTabsProps> = ({ languages, selectedLangs, activeLang, onSelect }) => {
return (
<div className="flex gap-2 flex-wrap">
{selectedLangs.map((code) => {
const langName = languages.find((l) => l.code === code)?.label || code;
return (
<Button
key={code}
variant={activeLang === code ? 'default' : 'outline'}
size="sm"
onClick={() => onSelect(code)}
>
{langName}
</Button>
);
})}
</div>
);
};
export default LanguageTabs;

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export interface Language { code: string; label: string; }
interface SocialSchedulingPanelProps {
showSocial: boolean;
onToggleSocial: () => void;
socialChannels: string[];
selectedChannels: string[];
onToggleChannel: (channel: string) => void;
languages: Language[];
selectedLangs: string[];
editedContents: Record<string, string>;
initialContents: Record<string, string>;
activeLang: string;
setActiveLang: (l: string) => void;
setEditedContents: React.Dispatch<React.SetStateAction<Record<string, string>>>;
scheduledTime: string;
setScheduledTime: (v: string) => void;
onPublish: () => void;
canPublish: boolean;
}
const SocialSchedulingPanel: React.FC<SocialSchedulingPanelProps> = ({
showSocial,
onToggleSocial,
socialChannels,
selectedChannels,
onToggleChannel,
languages,
selectedLangs,
editedContents,
initialContents,
activeLang,
setActiveLang,
setEditedContents,
scheduledTime,
setScheduledTime,
onPublish,
canPublish,
}) => {
return (
<div className="w-full max-w-3xl mx-auto mt-4 space-y-4">
<section className="rounded-lg border bg-muted/30 p-4">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={showSocial}
onChange={onToggleSocial}
className="accent-primary"
/>
Poste Social Media
</label>
<div className={`overflow-hidden transition-all duration-300 ${showSocial ? 'max-h-96 opacity-100 mt-2' : 'max-h-0 opacity-0'}`}>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium mr-1">Kanäle:</span>
{socialChannels.map((c) => (
<Button
key={c}
variant={selectedChannels.includes(c) ? 'default' : 'outline'}
size="sm"
onClick={() => onToggleChannel(c)}
>
{c}
</Button>
))}
</div>
<div className="flex flex-wrap items-center gap-2 mt-4">
<span className="text-sm font-medium mr-1">Sprachen:</span>
{languages.map((l) => (
<label key={l.code} className="flex items-center gap-1 cursor-pointer select-none">
<input
type="checkbox"
checked={Object.keys(editedContents).includes(l.code)}
disabled={!selectedLangs.includes(l.code)}
onChange={e => {
if (e.target.checked) {
setEditedContents(prev => ({
...prev,
[l.code]: initialContents[l.code] || ''
}));
setActiveLang(l.code);
} else {
setEditedContents(prev => {
const copy: Record<string, string> = { ...prev };
delete copy[l.code];
if (activeLang === l.code) {
const next = Object.keys(copy)[0] || '';
setActiveLang(next);
}
return copy;
});
}
}}
className="accent-primary"
/>
{l.label}
</label>
))}
</div>
</div>
</section>
<section className="rounded-lg border bg-muted/30 p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="radio"
id="publishNow"
name="publishOption"
checked={!scheduledTime}
onChange={() => setScheduledTime('')}
className="h-4 w-4 text-primary focus:ring-primary"
/>
<label htmlFor="publishNow" className="text-sm font-medium">
Sofort veröffentlichen
</label>
</div>
<div className="flex items-center gap-2">
<input
type="radio"
id="schedulePublish"
name="publishOption"
checked={!!scheduledTime}
onChange={() => setScheduledTime(new Date().toISOString().split('T')[0])}
className="h-4 w-4 text-primary focus:ring-primary"
/>
<label htmlFor="schedulePublish" className="text-sm font-medium">
Senden planen
</label>
</div>
</div>
{scheduledTime && (
<div className="flex items-center gap-2 mt-2">
<Input
type="date"
value={scheduledTime.split('T')[0]}
onChange={(e) => setScheduledTime(e.target.value)}
className="flex-1"
/>
</div>
)}
</section>
<div className="flex justify-end">
<Button onClick={onPublish} disabled={!canPublish}>Veröffentlichen</Button>
</div>
</div>
);
};
export default SocialSchedulingPanel;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BlogWizard from '@/features/blog/components/BlogWizard';
function makeGenerateResult() {
return {
blog: [
{ language: 'de', content: 'Hallo Welt' },
{ language: 'en', content: 'Hello World' },
],
};
}
describe('BlogWizard presenter - generating flow', () => {
it('topic + image upload -> generate -> result view', async () => {
const user = userEvent.setup();
const onNewBlog = vi.fn().mockResolvedValue('b1');
const onUploadFile = vi.fn().mockResolvedValue({ success: true, url: 'https://example.com/img.jpg' });
const onGenerate = vi.fn().mockResolvedValue(makeGenerateResult());
render(
<BlogWizard
onComplete={() => {}}
onNewBlog={onNewBlog}
onUploadFile={onUploadFile}
onGenerate={onGenerate}
/>
);
// Enter topic
const topicInput = screen.getByPlaceholderText(/Zum Beispiel:/i);
await user.type(topicInput, 'Mein Thema');
// Upload image using hidden file input
const fileInput = screen.getByRole('textbox', { hidden: true }) as HTMLInputElement | null;
// The hidden input is not a textbox role; safer: query by input type
const realFileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File([new Uint8Array([1,2,3])], 'image.jpg', { type: 'image/jpeg' });
fireEvent.change(realFileInput, { target: { files: [file] } });
// After upload, the uploaded image should be part of the grid (selectedMediaId is set by upload handler)
// Click Generate
const generateBtn = await screen.findByRole('button', { name: /Beitrag generieren/i });
await user.click(generateBtn);
// Loading state then result view with textarea
const textarea = await screen.findByRole('textbox');
expect((textarea as HTMLTextAreaElement).value).toContain('Hallo Welt');
// Ensure generators were called with expected arguments
expect(onNewBlog).toHaveBeenCalled();
expect(onGenerate).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BlogWizard from '@/features/blog/components/BlogWizard';
function makeGenerateResult() {
return {
blog: [
{ language: 'de', content: 'Hallo Welt' },
{ language: 'en', content: 'Hello World' },
],
};
}
describe('BlogWizard presenter - result save & publish', () => {
it('saves changes and can publish afterwards', async () => {
const user = userEvent.setup();
const onNewBlog = vi.fn().mockResolvedValue('b1');
const onUploadFile = vi.fn().mockResolvedValue({ success: true, url: 'https://example.com/img.jpg' });
const onGenerate = vi.fn().mockResolvedValue(makeGenerateResult());
const onUpdate = vi.fn().mockResolvedValue({ saved: true });
const onPublish = vi.fn().mockResolvedValue({ ok: true });
render(
<BlogWizard
onComplete={() => {}}
onNewBlog={onNewBlog}
onUploadFile={onUploadFile}
onGenerate={onGenerate}
onUpdate={onUpdate}
onPublish={onPublish}
/>
);
// Enter topic
const topicInput = screen.getByPlaceholderText(/Zum Beispiel:/i);
await user.type(topicInput, 'Topic');
// Upload image
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File([new Uint8Array([1,2,3])], 'image.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, file);
// Generate to go to result view
const generateBtn = await screen.findByRole('button', { name: /Beitrag generieren/i });
await user.click(generateBtn);
// Edit textarea to create changes
const textarea = await screen.findByRole('textbox');
await user.clear(textarea);
await user.type(textarea, 'Geänderter Inhalt');
// Save changes button should be enabled now, click save
const saveBtn = screen.getByRole('button', { name: /Änderungen speichern/i });
await user.click(saveBtn);
expect(onUpdate).toHaveBeenCalled();
// After save, Publish button should be enabled; click publish in SocialSchedulingPanel
const publishBtn = screen.getByRole('button', { name: /Veröffentlichen/i });
await user.click(publishBtn);
expect(onPublish).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BlogWizard from '@/features/blog/components/BlogWizard';
describe('BlogWizard - suggestions', () => {
it('loads suggestions and allows selecting one to fill topic', async () => {
const user = userEvent.setup();
const onFetchSuggestions = vi.fn().mockResolvedValue([
'Thema A',
'Thema B',
] as string[]);
render(
<BlogWizard
onComplete={() => {}}
onFetchSuggestions={onFetchSuggestions}
/>
);
// Click the suggestion button
const suggestBtn = await screen.findByRole('button', { name: /Thema vorschlagen lassen/i });
await user.click(suggestBtn);
// Suggestions popover/list should appear
const item = await screen.findByRole('button', { name: 'Thema A' });
await user.click(item);
// Topic input should now contain selected suggestion
const input = await screen.findByPlaceholderText(/Zum Beispiel:/i);
expect((input as HTMLInputElement).value).toBe('Thema A');
});
});

View File

View File

@@ -0,0 +1,103 @@
import React from 'react';
import BlogWizard from '@/features/blog/components/BlogWizard';
import { newBlog, generateBlogPost, updateBlogPost, publishBlogPost, fetchBlogTopicSuggestions } from '@/features/blog/api/blogService';
import { uploadBlogFile, generateBlogPostImage } from '@/shared/api/fileService';
import { toast } from 'sonner';
interface BlogWizardContainerProps {
onComplete: () => void;
}
const BlogWizardContainer: React.FC<BlogWizardContainerProps> = ({ onComplete }) => {
const handleNewBlog = async () => {
const id = await newBlog();
if (!id) throw new Error('Konnte keinen neuen Blog erstellen');
return id;
};
const handleGenerate = async (args: { blog_Id: string; topic: string; tonality: string; languages: string[]; }) => {
try {
const res = await generateBlogPost(args);
return res;
} catch (err) {
toast.error('Fehler beim Generieren des Blogbeitrags');
throw err;
}
};
const handleUpdate = async (args: { blogId: string; content: any; }) => {
try {
const res = await updateBlogPost(args);
toast.success('Änderungen erfolgreich gespeichert');
return res;
} catch (err) {
toast.error('Fehler beim Speichern der Änderungen');
throw err;
}
};
const handlePublish = async (args: {
blogId: string;
publishDate?: string;
publishToSocialMedia: boolean;
socialMediaChannels: string[];
socialMediaLanguages: string[];
}) => {
try {
const res = await publishBlogPost(args);
toast.success(args.publishDate ? 'Blog erfolgreich geplant' : 'Blog erfolgreich veröffentlicht');
onComplete?.();
return res;
} catch (err) {
toast.error('Fehler beim Veröffentlichen des Blogbeitrags');
throw err;
}
};
const handleFetchSuggestions = async (): Promise<string[] | null> => {
try {
const suggestions = await fetchBlogTopicSuggestions();
return suggestions;
} catch (err) {
toast.error('Fehler beim Laden der Themenvorschläge');
return null;
}
};
const handleUploadFile = async (file: File, blogId: string) => {
try {
const res = await uploadBlogFile(file, blogId);
if (!res.success) throw new Error(res.message);
return res;
} catch (err) {
toast.error('Fehler beim Hochladen der Datei');
throw err;
}
};
const handleGenerateImage = async (blogId: string, title: string) => {
try {
const res = await generateBlogPostImage(blogId, title);
if (!res.success) throw new Error(res.message);
return res;
} catch (err) {
toast.error('Fehler beim Generieren des Bildes');
throw err;
}
};
return (
<BlogWizard
onComplete={onComplete}
onNewBlog={handleNewBlog}
onGenerate={handleGenerate}
onUpdate={handleUpdate}
onPublish={handlePublish}
onFetchSuggestions={handleFetchSuggestions}
onUploadFile={handleUploadFile}
onGenerateImage={handleGenerateImage}
/>
);
};
export default BlogWizardContainer;

View File

@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import BlogWizardContainer from '@/features/blog/containers/BlogWizardContainer';
// Mocks for blogService
const publishBlogPostMock = vi.fn();
const updateBlogPostMock = vi.fn();
const fetchBlogTopicSuggestionsMock = vi.fn();
const newBlogMock = vi.fn();
const generateBlogPostMock = vi.fn();
vi.mock('@/features/blog/api/blogService', async () => {
const mod = (await vi.importActual<typeof import('@/features/blog/api/blogService')>(
'@/features/blog/api/blogService'
)) as any;
return {
...(mod as any),
publishBlogPost: (...args: any[]) => publishBlogPostMock(...args),
updateBlogPost: (...args: any[]) => updateBlogPostMock(...args),
fetchBlogTopicSuggestions: (...args: any[]) => fetchBlogTopicSuggestionsMock(...args),
newBlog: (...args: any[]) => newBlogMock(...args),
generateBlogPost: (...args: any[]) => generateBlogPostMock(...args),
};
});
// Mocks for fileService (not used in these tests but required by container callbacks shape)
const uploadBlogFileMock = vi.fn();
const generateBlogPostImageMock = vi.fn();
vi.mock('@/shared/api/fileService', () => ({
uploadBlogFile: (...args: any[]) => uploadBlogFileMock(...args),
generateBlogPostImage: (...args: any[]) => generateBlogPostImageMock(...args),
}));
// Mock BlogWizard presenter to invoke callbacks
vi.mock('@/features/blog/components/BlogWizard', () => ({
default: (props: any) => {
useEffect(() => {
// no-op on mount
}, []);
return (
<div>
<button
onClick={() => {
const p = props.onPublish?.({
blogId: 'b1',
publishDate: '2025-01-01',
publishToSocialMedia: true,
socialMediaChannels: ['LinkedIn'],
socialMediaLanguages: ['de'],
});
if (p && typeof p.catch === 'function') p.catch(() => {});
}}
>
trigger-publish
</button>
<button onClick={() => props.onFetchSuggestions?.()}>trigger-suggest</button>
</div>
);
}
}));
// Hoist-safe sonner mock
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { toast } from 'sonner';
beforeEach(() => {
vi.restoreAllMocks();
publishBlogPostMock.mockReset();
updateBlogPostMock.mockReset();
fetchBlogTopicSuggestionsMock.mockReset();
newBlogMock.mockReset();
generateBlogPostMock.mockReset();
uploadBlogFileMock.mockReset();
generateBlogPostImageMock.mockReset();
(toast.success as any).mockReset?.();
(toast.error as any).mockReset?.();
});
describe('BlogWizardContainer (integration)', () => {
it('calls publish and shows success toast, then onComplete', async () => {
publishBlogPostMock.mockResolvedValueOnce({ ok: true });
const onComplete = vi.fn();
render(<BlogWizardContainer onComplete={onComplete} />);
// Click trigger to call onPublish
await screen.findByText('trigger-publish');
screen.getByText('trigger-publish').click();
// success toast and onComplete should be called
expect(publishBlogPostMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect((toast.success as any)).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
});
it('handles publish error and shows error toast', async () => {
publishBlogPostMock.mockRejectedValueOnce(new Error('fail'));
render(<BlogWizardContainer onComplete={() => {}} />);
await screen.findByText('trigger-publish');
screen.getByText('trigger-publish').click();
expect(publishBlogPostMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect((toast.error as any)).toHaveBeenCalledTimes(1));
});
});

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import BlogWizardContainer from '@/features/blog/containers/BlogWizardContainer';
import { type DashboardModule } from '@/components/dashboard/dashboardModuleTypes';
export const blogDashboardModule: DashboardModule = {
id: 'blog',
getTitle: () => 'Blog',
getDescription: () => 'Erstelle und verwalte Blogbeiträge',
getIllustration: () => '/images/robo/robo_blogauthor.png',
renderWizard: (_ctx, onBack) => (
<div className="space-y-6">
<Button
variant="ghost"
className="mb-4"
onClick={onBack}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück zur Übersicht
</Button>
<BlogWizardContainer onComplete={onBack} />
</div>
),
};

View File

View File

View File

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Deterministic base URL
vi.mock('@/lib/config', () => ({ getWebhookUrl: () => 'https://api.test' }));
// apiFetch mock
const apiFetchMock = vi.fn();
vi.mock('@/utils/apiFetch', () => ({
apiFetch: (...args: any[]) => apiFetchMock(...args),
}));
import { fetchEvents, fetchCurrentEvents, createEventFromUrl } from '@/features/event/api/eventService';
function okJson(data: any) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function status(code: number) {
return new Response('', { status: code });
}
beforeEach(() => {
apiFetchMock.mockReset();
});
describe('eventService API', () => {
it('fetchEvents returns array data', async () => {
apiFetchMock.mockResolvedValueOnce(okJson([{ id: 'e1' }, { id: 'e2' }]));
const list = await fetchEvents(async (...args: any[]) => apiFetchMock(...args));
expect(apiFetchMock).toHaveBeenCalledWith('https://api.test/events', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
expect(Array.isArray(list)).toBe(true);
expect(list).toHaveLength(2);
});
it('fetchCurrentEvents returns normalized array when API returns single object', async () => {
apiFetchMock.mockResolvedValueOnce(okJson({ id: 'single' }));
const list = await fetchCurrentEvents(async (...args: any[]) => apiFetchMock(...args));
expect(apiFetchMock).toHaveBeenCalledWith('https://api.test/events/get_current', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
expect(Array.isArray(list)).toBe(true);
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ id: 'single' });
});
it('createEventFromUrl posts URL and returns parsed data.message_from_ai when present', async () => {
apiFetchMock.mockResolvedValueOnce(okJson({ message_from_ai: { name: 'AI Event' } }));
const result = await createEventFromUrl('https://example.com');
const [url, init] = apiFetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://api.test/event/new_fromurl');
expect((init?.headers as any)['Content-Type']).toBe('application/json');
expect(JSON.parse(String(init?.body))).toMatchObject({ url: 'https://example.com', command: 'eventmodus_extract_from_url' });
expect(result).toEqual({ name: 'AI Event' });
});
it('fetchEvents throws on non-ok response', async () => {
apiFetchMock.mockResolvedValueOnce(status(500));
await expect(fetchEvents(async (...args: any[]) => apiFetchMock(...args))).rejects.toThrow();
});
});

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { formatDateForInput } from '@/features/event/api/eventService';
describe('eventService.formatDateForInput', () => {
it('formats a valid ISO date to YYYY-MM-DD', () => {
expect(formatDateForInput('2024-12-31T10:20:30Z')).toBe('2024-12-31');
expect(formatDateForInput('2024-01-05')).toBe('2024-01-05');
});
it('returns empty string for falsy input', () => {
expect(formatDateForInput('')).toBe('');
});
});

View File

@@ -0,0 +1,435 @@
import { getWebhookUrl } from '@/lib/config';
export interface Event {
id?: string | number;
user_id?: number;
name?: string;
title?: string; // neuer Titel-Name vom Timeline-Endpoint
description?: string;
location?: string;
url?: string;
image?: string;
manager_name?: string;
manager_email?: string;
guests?: any;
begin_date?: string; // ISO date string (YYYY-MM-DD)
end_date?: string; // ISO date string (YYYY-MM-DD)
eventDate?: string; // ISO date string (YYYY-MM-DD) vom Timeline-Endpoint (camelCase)
event_date?: string; // ISO date string (YYYY-MM-DD) direkt aus SQL-Query
status?: 'PENDING' | 'ON_AIR'; // aggregierter Status für Timeline
overall_status?: 'PENDING' | 'ON_AIR'; // Alias-Name aus SQL-Query
creation_date?: number;
last_update?: number;
}
// Payload-Struktur für den neuen Event-Endpunkt
interface CreateEventPayload {
title: string;
description?: string;
eventDate: string; // YYYY-MM-DD
promotionStartDate?: string; // YYYY-MM-DD
coverMediaUrl?: string;
ticketUrl?: string;
admissionTimes?: string;
ticketPrice?: string;
promotionSlots: Array<{
slotIndex: number;
slotType: 'EVENT_MAIN' | 'ARTIST';
promoDate: string; // YYYY-MM-DD
artistName?: string;
artistInstagram?: string;
}>;
}
export interface EventDetail extends CreateEventPayload {
id: string | number;
}
export const createEvent = async (payload: CreateEventPayload): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/new`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('Error creating event manually:', error);
throw error;
}
};
export interface ArtistCollabUpdateByOpPayload {
eventId: string | number;
slotId?: string | number;
slotIndex?: number;
artistDescription?: string;
artistInstagram?: string;
artistFacebook?: string;
artistLinkedin?: string;
artistTiktok?: string;
artistYoutube?: string;
}
export const updateArtistCollabDataByOp = async (
payload: ArtistCollabUpdateByOpPayload
): Promise<{ success: boolean; message?: string }> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/update_artist_data_byop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const raw = await response.json();
const data = Array.isArray(raw) ? raw[0] ?? {} : raw ?? {};
return {
success: data.success !== false,
message: data.message,
};
} catch (error) {
console.error('Error updating artist collab data by op:', error);
return { success: false, message: error instanceof Error ? error.message : 'Unknown error' };
}
};
export const getEventInfoFromUrl = async (url: string): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/getinfofromurl`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error getting event info from URL:', error);
throw error;
}
};
export const fetchCurrentEvents = async (apiFetch: Function): Promise<Event[]> => {
const webhookUrl = `${getWebhookUrl()}/events/get_current`;
try {
const response = await apiFetch(webhookUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('Failed to fetch events');
const raw = await response.json();
// n8n kann die Liste ggf. als String-JSON zurückgeben
const data =
typeof raw === 'string'
? (() => {
try {
return JSON.parse(raw);
} catch {
return raw;
}
})()
: raw;
return Array.isArray(data) ? data : data ? [data] : [];
} catch (error) {
console.error('Error fetching events:', error);
throw error;
}
}
export interface ArtistCollabUpdatePayload {
token: string;
slotId?: string | number;
eventId?: string | number;
artistDescription?: string;
artistInstagram?: string;
artistFacebook?: string;
artistLinkedin?: string;
artistTiktok?: string;
artistYoutube?: string;
}
export const updateArtistCollabData = async (
payload: ArtistCollabUpdatePayload
): Promise<{ success: boolean; message?: string }> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/update_artist_data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const raw = await response.json();
const data = Array.isArray(raw) ? raw[0] ?? {} : raw ?? {};
return {
success: data.success !== false,
message: data.message,
};
} catch (error) {
console.error('Error updating artist collab data:', error);
return { success: false, message: error instanceof Error ? error.message : 'Unknown error' };
}
};
export const fetchEventById = async (
apiFetch: Function,
id: string | number
): Promise<EventDetail> => {
const webhookUrl = `${getWebhookUrl()}/events/getbyid?id=${id}`;
const response = await apiFetch(webhookUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) throw new Error('Failed to fetch event');
const raw = await response.json();
// n8n kann entweder ein Objekt, ein Array mit einem Objekt
// oder einen JSON-String zurückgeben hier alles abfangen.
const data =
typeof raw === 'string'
? (() => {
try {
return JSON.parse(raw);
} catch {
return raw;
}
})()
: raw;
if (Array.isArray(data)) {
return (data[0] || {}) as EventDetail;
}
return data as EventDetail;
};
export const updateEvent = async (
id: string | number,
payload: CreateEventPayload
): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id, ...payload }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('Error updating event:', error);
throw error;
}
};
export const fetchEvents = async (apiFetch: Function): Promise<Event[]> => {
const webhookUrl = `${getWebhookUrl()}/events`;
try {
const response = await apiFetch(webhookUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('Failed to fetch events');
const data = await response.json();
return Array.isArray(data) ? data : data ? [data] : [];
} catch (error) {
console.error('Error fetching events:', error);
throw error;
}
}
export const createEventFromUrl = async (url: string): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/event/new_fromurl`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
command: 'eventmodus_extract_from_url'
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data.message_from_ai || data;
} catch (error) {
console.error('Error creating event from URL:', error);
throw error;
}
};
// Helper function to format dates to YYYY-MM-DD
export const formatDateForInput = (dateString: string): string => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toISOString().split('T')[0];
};
export interface CollabLinkResponse {
token: string;
url?: string;
[key: string]: any;
}
export const generateCollabLink = async (
eventId: string | number,
slotIndex: number,
artistName?: string,
artistInstagram?: string
): Promise<CollabLinkResponse> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/generate_collab_link`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ eventId, slotIndex, artistName, artistInstagram }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = (await response.json()) as CollabLinkResponse;
return data;
} catch (error) {
console.error('Error generating collaboration link:', error);
throw error;
}
};
export interface CollabMediaItem {
id: string;
url: string;
type?: string;
}
export interface CollabInfo {
eventTitle: string;
promoDate?: string;
artistName?: string;
artistDescription?: string;
artistMediaUrl?: string;
artistInstagram?: string;
slotId?: string | number;
eventId?: string | number;
media?: CollabMediaItem[];
}
export const fetchArtistSlotByToken = async (token: string): Promise<CollabInfo | null> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/events/slots/getartistsslot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
if (response.status === 401 || response.status === 403 || response.status === 404) {
// Token ungültig, abgelaufen oder Slot nicht gefunden
return null;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const raw = await response.json();
const data: any = (() => {
if (!raw) return null;
if (Array.isArray(raw)) return raw[0] ?? null;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed[0] ?? null : parsed;
} catch {
return null;
}
}
return raw as any;
})();
if (!data) return null;
return {
eventTitle: data.eventTitle ?? data.event_title ?? data.title ?? '',
promoDate: data.promoDate ?? data.promo_date,
artistName: data.artistName ?? data.artist_name,
artistDescription: data.artistDescription ?? data.artist_description,
artistMediaUrl: data.artistMediaUrl ?? data.artist_media_url,
artistInstagram: data.artistInstagram ?? data.artist_instagram,
slotId: data.id ?? data.slot_id,
eventId: data.event_id ?? data.eventId,
media: Array.isArray(data.media)
? data.media.map((m: any) => ({
id: String(m.id),
url: m.url || m.file_url,
type: m.type || m.file_type,
}))
: [],
};
} catch (error) {
console.error('Error fetching artist slot by token:', error);
throw error;
}
};

View File

@@ -0,0 +1,51 @@
import { getWebhookUrl } from '@/lib/config';
export interface EventMediaItem {
id: string;
url: string;
}
export const getEventMedia = async (eventId?: string): Promise<EventMediaItem[]> => {
if (!eventId) {
return [];
}
const endpoint = `${getWebhookUrl()}/event/get_event_media?event_id=${encodeURIComponent(eventId)}`;
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(endpoint, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Fehler beim Laden der Event Bilder!");
}
const data = await response.json();
let normalized: EventMediaItem[] = [];
if (
Array.isArray(data) &&
data.length > 0 &&
typeof data[0].output === "string"
) {
try {
const outputArr = JSON.parse(data[0].output);
normalized = outputArr.map((item: any, idx: number) => ({
id: item.id?.toString() || `remote-${idx}`,
url: item.url || item.file_url,
}));
} catch (e) {
console.error("Fehler beim Parsen von data[0].output:", e);
}
} else if (Array.isArray(data)) {
normalized = data.map((item: any, idx: number) => ({
id: item.id?.toString() || `remote-${idx}`,
url: item.url || item.file_url,
}));
}
return normalized;
} catch (e) {
console.error("Fehler beim Laden der Event Bilder", e);
return [];
}
};

View File

@@ -0,0 +1,320 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { fetchArtistSlotByToken, CollabInfo, updateArtistCollabData } from '@/features/event/api/eventService';
import { Facebook, Instagram, Linkedin, Music2, Youtube } from 'lucide-react';
import MediaUpload, { MediaItem, MediaItemType } from '@/shared/components/MediaUpload';
import { getWebhookUrl } from '@/lib/config';
import { uploadMediaWithContext, deleteMediaWithContext } from '@/services/fileService';
const ArtistCollabForm: React.FC = () => {
const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const [data, setData] = useState<CollabInfo | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [media, setMedia] = useState<MediaItem[]>([]);
const [instagram, setInstagram] = useState('');
const [facebook, setFacebook] = useState('');
const [linkedin, setLinkedin] = useState('');
const [tiktok, setTiktok] = useState('');
const [youtube, setYoutube] = useState('');
useEffect(() => {
if (!token) {
setError('Ungültiger Link.');
setLoading(false);
return;
}
const load = async () => {
setLoading(true);
setError(null);
try {
const info = await fetchArtistSlotByToken(token);
if (!info) {
setError('Link ungültig oder abgelaufen.');
return;
}
setData(info);
setName(info.artistName || '');
setDescription(info.artistDescription || '');
setInstagram(info.artistInstagram || '');
if (Array.isArray(info.media)) {
setMedia(
info.media.map((m) => ({
id: m.id,
type: m.type === 'video' ? 'video' : 'image',
url: m.url,
}))
);
}
} catch (e) {
setError('Link ungültig oder abgelaufen.');
} finally {
setLoading(false);
}
};
load();
}, [token]);
const handleArtistMediaUpload = async ({ file, type }: { file: File; type: MediaItemType }) => {
if (!token) {
throw new Error('Kein gültiger Token für den Upload vorhanden.');
}
if (!data?.slotId) {
throw new Error('Keine Slot-ID für den Upload vorhanden.');
}
const response = await uploadMediaWithContext(file, {
endpoint: '/events/artist_upload',
id: data.slotId,
token,
type,
extraFields: {
eventId: data.eventId,
},
});
if (!response.success || !(response.url || response.fileUrl)) {
throw new Error(response.message || 'Upload fehlgeschlagen');
}
return {
url: response.url || response.fileUrl!,
id: response.id,
};
};
const handleArtistMediaDelete = async (item: MediaItem) => {
if (!token) {
throw new Error('Kein gültiger Token für die Löschung vorhanden.');
}
const response = await deleteMediaWithContext({
endpoint: '/events/artist_remove',
mediaId: item.id,
token,
extraFields: {
slotId: data?.slotId,
eventId: data?.eventId,
},
});
if (!response.success) {
throw new Error(response.message || 'Löschung fehlgeschlagen');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setError(null);
setSubmitting(true);
try {
const result = await updateArtistCollabData({
token,
slotId: data?.slotId,
eventId: data?.eventId,
artistDescription: description || undefined,
artistInstagram: instagram || undefined,
artistFacebook: facebook || undefined,
artistLinkedin: linkedin || undefined,
artistTiktok: tiktok || undefined,
artistYoutube: youtube || undefined,
});
if (!result.success) {
throw new Error(result.message || 'Daten konnten nicht gespeichert werden.');
}
setSubmitted(true);
} catch (e) {
setError('Daten konnten nicht gespeichert werden.');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-sm text-muted-foreground">Lade Link</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-sm text-destructive">{error}</div>
</div>
);
}
if (!data) {
return null;
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full rounded-lg border p-6 space-y-2 text-center">
<div className="text-lg font-semibold">Vielen Dank!</div>
<div className="text-sm text-muted-foreground">
Deine Daten wurden übermittelt.
</div>
</div>
</div>
);
}
const deadlineText = (() => {
if (!data?.promoDate) return null;
const d = new Date(data.promoDate);
if (isNaN(d.getTime())) return null;
d.setDate(d.getDate() - 1);
return d.toLocaleDateString('de-DE');
})();
return (
<div className="min-h-screen flex flex-col p-4 bg-background">
<header className="mb-6 border-b pb-3">
<div className="max-w-4xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<img
src="/Logo.png"
alt="Kimaschine Logo"
className="h-10 w-auto object-contain"
/>
</div>
<div className="text-center flex-1">
{/* <div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Eventmodul</div> */}
<div className="text-lg font-semibold tracking-tight">Kunst gegen Bares Düsseldorf</div>
</div>
<div className="flex items-center justify-end">
<img
src="/kgblogo.png"
alt="Kunst gegen Bares Logo"
className="h-10 w-auto object-contain"
/>
</div>
</div>
</header>
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-xl rounded-lg border p-6 space-y-4 bg-background">
<div>
{/* <div className="text-xs uppercase tracking-wide text-muted-foreground">Künstler-Formular</div> */}
<h1 className="text-xl font-semibold">
Hallo {data.artistName || name}, wir freuen uns, dich zum<br/>
"{data.eventTitle}"<br/>
im Ratinger Hof zu begrüßen.
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Im Rahmen unserer Promotion bitten wir dich, dich hier kurz vorzustellen.
{deadlineText && <> Bitte sende uns die Daten bis spätestens {deadlineText}.</>}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="description">Kurzbeschreibung</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
/>
</div>
<div className="space-y-2">
<MediaUpload
label="Bild / Video"
description="Lade bis zu 3 Bilder und optional ein Video hoch. Bilder werden direkt hochkant zugeschnitten."
value={media}
onChange={setMedia}
maxImages={3}
allowVideo
aspectRatio={9 / 16}
uploadFile={handleArtistMediaUpload}
onRemoveItem={handleArtistMediaDelete}
/>
</div>
<div className="space-y-2">
<Label>Deine Social-Media-Profile (optional)</Label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Facebook className="h-4 w-4 text-muted-foreground" />
<Input
id="facebook"
value={facebook}
onChange={(e) => setFacebook(e.target.value)}
placeholder="Facebook Benutzername oder Profil-URL"
/>
</div>
<div className="flex items-center gap-2">
<Instagram className="h-4 w-4 text-muted-foreground" />
<Input
id="instagram"
value={instagram}
onChange={(e) => setInstagram(e.target.value)}
placeholder="Instagram Benutzername (@…)"
/>
</div>
<div className="flex items-center gap-2">
<Linkedin className="h-4 w-4 text-muted-foreground" />
<Input
id="linkedin"
value={linkedin}
onChange={(e) => setLinkedin(e.target.value)}
placeholder="LinkedIn Profil (Name oder URL)"
/>
</div>
<div className="flex items-center gap-2">
<Music2 className="h-4 w-4 text-muted-foreground" />
<Input
id="tiktok"
value={tiktok}
onChange={(e) => setTiktok(e.target.value)}
placeholder="TikTok Benutzername (@…)"
/>
</div>
<div className="flex items-center gap-2">
<Youtube className="h-4 w-4 text-muted-foreground" />
<Input
id="youtube"
value={youtube}
onChange={(e) => setYoutube(e.target.value)}
placeholder="YouTube Kanal (Name oder URL)"
/>
</div>
</div>
</div>
<div className="pt-2 flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting ? 'Sende…' : 'Daten senden'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default ArtistCollabForm;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { type Event } from '@/features/event/api/eventService';
import EventFormInline from './EventFormInline';
interface EventWizardProps {
onComplete: () => void;
events: Event[];
loading: boolean;
error: string | null;
onReload: () => void;
}
const EventWizard: React.FC<EventWizardProps> = ({ onComplete, events, loading, error, onReload }) => {
const [showForm, setShowForm] = useState(false);
const [activeEventId, setActiveEventId] = useState<string | number | null>(null);
useEffect(() => {
onReload();
}, [onReload]);
const sortedEvents = useMemo(() => {
if (!events.length) return [] as Event[];
return [...events].sort((a, b) => {
const aDate = a?.eventDate || a?.event_date || a?.begin_date;
const bDate = b?.eventDate || b?.event_date || b?.begin_date;
const aTime = aDate ? new Date(aDate).getTime() : 0;
const bTime = bDate ? new Date(bDate).getTime() : 0;
return aTime - bTime;
});
}, [events]);
const activeRef = useRef<HTMLDivElement | null>(null);
const todayIndex = useMemo(() => {
if (!sortedEvents.length) return -1;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Finde das erste Event mit Datum heute oder in der Zukunft
const upcomingIndex = sortedEvents.findIndex((event) => {
const baseDate = event?.eventDate || event?.event_date || event?.begin_date;
if (!baseDate) return false;
const date = new Date(baseDate);
if (Number.isNaN(date.getTime())) return false;
date.setHours(0, 0, 0, 0);
return date.getTime() >= today.getTime();
});
// Wenn nichts in der Zukunft liegt, scrolle zum letzten Event (jüngstes vergangenes)
if (upcomingIndex === -1) {
return sortedEvents.length - 1;
}
return upcomingIndex;
}, [sortedEvents]);
useEffect(() => {
if (todayIndex === -1) return;
if (!activeRef.current) return;
activeRef.current.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}, [todayIndex, sortedEvents]);
const formatDate = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return dateString;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
return (
<Card className="w-full mx-auto md:max-w-5xl">
<CardHeader>
<CardTitle>Veranstaltungen</CardTitle>
</CardHeader>
<CardContent>
{showForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={() => {
setShowForm(false);
setActiveEventId(null);
}}
>
Zurück zur Timeline
</Button>
</div>
<EventFormInline
mode={activeEventId ? 'edit' : 'create'}
eventId={activeEventId}
onSuccess={() => {
setShowForm(false);
setActiveEventId(null);
onReload();
}}
onCancel={() => {
setShowForm(false);
setActiveEventId(null);
}}
/>
</div>
) : loading ? (
<div className="text-sm text-muted-foreground py-10">Events werden geladen...</div>
) : error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</div>
) : (
<div className="space-y-6">
<div className="pb-2">
<div className="mb-2 text-xs text-muted-foreground">
{sortedEvents.length} Veranstaltung{sortedEvents.length === 1 ? '' : 'en'} geladen
</div>
<div className="relative sm:min-h-[200px]">
<div className="hidden sm:block absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-muted-foreground/30" />
<div className="relative -mx-4 sm:mx-0 px-4 sm:px-0 overflow-x-auto py-4 sm:py-8">
<div className="flex flex-row gap-4 sm:gap-6">
{sortedEvents.map((event, index) => {
const isToday = index === todayIndex && todayIndex !== -1;
const title = (event as any).title || event.name || 'Unbenanntes Event';
const singleDate = (event as any).eventDate || (event as any).event_date || event.begin_date;
const status = ((event as any).status || (event as any).overall_status) as 'PENDING' | 'ON_AIR' | undefined;
// Status nur für heutige oder zukünftige Events anzeigen
let statusToShow: 'PENDING' | 'ON_AIR' | undefined = status;
if (singleDate) {
const d = new Date(singleDate);
if (!Number.isNaN(d.getTime())) {
const today = new Date();
today.setHours(0, 0, 0, 0);
d.setHours(0, 0, 0, 0);
if (d.getTime() < today.getTime()) {
statusToShow = undefined;
}
}
}
const statusLabel = statusToShow === 'ON_AIR' ? 'On Air' : 'Pending';
const statusClass = statusToShow === 'ON_AIR' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700';
return (
<div
key={event.id || `${event.name}-${index}`}
ref={isToday ? activeRef : undefined}
className="flex w-[220px] sm:w-auto flex-shrink-0 sm:flex-shrink min-w-0 sm:min-w-[240px] flex-col items-stretch sm:items-center text-left sm:text-center cursor-pointer"
onClick={() => {
const eid = event.id ?? `${event.name || 'event'}-${index}`;
setActiveEventId(eid as any);
setShowForm(true);
}}
>
<div className={`hidden sm:block h-3 w-3 rounded-full border-2 ${isToday ? 'border-primary bg-primary' : 'border-muted-foreground/40 bg-background'}`} />
<div className={`mt-2 sm:mt-4 w-full rounded-lg border p-4 text-left transition-shadow ${isToday ? 'border-primary/60 shadow-lg' : 'border-border'}`}>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold leading-tight line-clamp-2">{title}</div>
{statusToShow && (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusClass}`}>
{statusLabel}
</span>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">{formatDate(singleDate)}</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => {
setActiveEventId(null);
setShowForm(true);
}}
>
Neu erstellen
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
};
export default EventWizard;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EventWizard from '@/features/event/components/EventWizard';
import type { Event } from '@/features/event/api/eventService';
// Mock PhotoPostWizard presenter to avoid heavy UI while verifying selection behavior
vi.mock('@/features/photopost/components/PhotoPostWizard', () => ({
default: ({ eventId }: { eventId: string | number }) => (
<div data-testid="photopost-wizard">PhotoPost for {String(eventId)}</div>
),
}));
describe('EventWizard presenter', () => {
it('shows loading state', () => {
render(
<EventWizard onComplete={() => {}} events={[]} loading={true} error={null} onReload={() => {}} />
);
expect(screen.getByText(/Events werden geladen/i)).toBeInTheDocument();
});
it('shows error state', () => {
render(
<EventWizard onComplete={() => {}} events={[]} loading={false} error="Fehler" onReload={() => {}} />
);
expect(screen.getByText('Fehler')).toBeInTheDocument();
});
it('lists events and allows selecting one to open PhotoPostWizard', async () => {
const user = userEvent.setup();
const events: Event[] = [
{ id: 'e1', name: 'Event Eins', begin_date: '2025-01-01', end_date: '2025-01-02' },
{ id: 'e2', name: 'Event Zwei', begin_date: '2025-02-01', end_date: '2025-02-02' },
];
render(
<EventWizard onComplete={() => {}} events={events} loading={false} error={null} onReload={() => {}} />
);
// both event names are rendered
expect(screen.getByText('Event Eins')).toBeInTheDocument();
expect(screen.getByText('Event Zwei')).toBeInTheDocument();
// select the first event by clicking on its name (event container is clickable)
await user.click(screen.getByText('Event Eins'));
// PhotoPostWizard (mock) should be visible with the eventId
expect(await screen.findByTestId('photopost-wizard')).toHaveTextContent('e1');
});
});

View File

@@ -0,0 +1,43 @@
import React, { useCallback, useState } from 'react';
import EventWizard from '@/features/event/components/EventWizard';
import { useApiFetch } from '@/utils/apiFetch';
import { fetchEvents, type Event } from '@/features/event/api/eventService';
import { toast } from 'sonner';
interface EventWizardContainerProps {
onComplete: () => void;
}
const EventWizardContainer: React.FC<EventWizardContainerProps> = ({ onComplete }) => {
const apiFetch = useApiFetch();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await fetchEvents(apiFetch);
setEvents(Array.isArray(list) ? list : []);
} catch (e: any) {
const msg = e?.message || 'Fehler beim Laden der Veranstaltungen';
setError(msg);
toast.error(msg);
} finally {
setLoading(false);
}
}, [apiFetch]);
return (
<EventWizard
onComplete={onComplete}
events={events}
loading={loading}
error={error}
onReload={reload}
/>
);
};
export default EventWizardContainer;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import EventWizardContainer from '@/features/event/containers/EventWizardContainer';
import { renderWithProviders } from '@/test/utils';
// Mock fetchEvents from eventService (keep simple to avoid spread typing issues)
const fetchEventsMock = vi.fn();
vi.mock('@/features/event/api/eventService', () => ({
fetchEvents: (...args: any[]) => fetchEventsMock(...args),
}));
// Mock useApiFetch to return a passthrough fetch function
vi.mock('@/utils/apiFetch', () => ({
useApiFetch: () => ((input: RequestInfo, init?: RequestInit) => fetch(input, init)),
}));
// Mock sonner in a hoist-safe way
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { toast } from 'sonner';
// Mock EventWizard presenter to call onReload and render event names
vi.mock('@/features/event/components/EventWizard', () => ({
default: (props: any) => {
React.useEffect(() => { props.onReload?.(); }, []);
return (
<div>
{(props.events || []).map((e: any) => (
<div key={e.id}>{e.name}</div>
))}
</div>
);
},
}));
beforeEach(() => {
vi.restoreAllMocks();
fetchEventsMock.mockReset();
(toast.error as any).mockReset?.();
});
describe('EventWizardContainer (integration)', () => {
it('loads and passes events to presenter', async () => {
fetchEventsMock.mockResolvedValueOnce([
{ id: 'e1', name: 'Event Eins', begin_date: '2025-01-01', end_date: '2025-01-02' },
]);
const { Wrapper } = renderWithProviders(<div />);
render(
<EventWizardContainer onComplete={() => {}} />,
{ wrapper: Wrapper as any }
);
// Presenter should render the event name once loaded
const el = await screen.findByText('Event Eins');
expect(el).toBeTruthy();
});
it('handles fetch error and calls toast.error', async () => {
fetchEventsMock.mockRejectedValueOnce(new Error('Boom'));
const { Wrapper } = renderWithProviders(<div />);
render(
<EventWizardContainer onComplete={() => {}} />,
{ wrapper: Wrapper as any }
);
await waitFor(() => {
expect((toast.error as any)).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import EventWizardContainer from '@/features/event/containers/EventWizardContainer';
import { type DashboardModule } from '@/components/dashboard/dashboardModuleTypes';
export const eventDashboardModule: DashboardModule = {
id: 'event',
getTitle: () => 'Veranstaltung',
getDescription: () => 'Verwalte deine Veranstaltungen',
getIllustration: () => '/images/robo/robo_event.png',
renderWizard: (_ctx, onBack) => (
<div className="space-y-6">
<Button
variant="ghost"
className="mb-4"
onClick={onBack}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück zur Übersicht
</Button>
<EventWizardContainer onComplete={onBack} />
</div>
),
};

View File

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/config', () => ({ getWebhookUrl: () => 'https://api.test' }));
const apiFetchMock = vi.fn();
vi.mock('@/utils/apiFetch', () => ({
apiFetch: (...args: any[]) => apiFetchMock(...args),
}));
import { generateSocialMediaPosts, publishSocialMediaPost } from '@/features/photopost/api/socialPostService';
function okJson(data: any) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function status(code: number) {
return new Response('', { status: code });
}
beforeEach(() => {
apiFetchMock.mockReset();
});
describe('socialPostService API', () => {
it('generateSocialMediaPosts posts payload and returns json', async () => {
apiFetchMock.mockResolvedValueOnce(okJson({ output: {} }));
const payload = { eventId: 'e1', images: ['img'], description: 'desc' };
const result = await generateSocialMediaPosts(payload);
expect(apiFetchMock).toHaveBeenCalledWith('https://api.test/socialmedia/post2event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
expect(result).toEqual({ output: {} });
});
it('publishSocialMediaPost posts payload and returns json', async () => {
apiFetchMock.mockResolvedValueOnce(okJson({ status: 'sucess' }));
const args = { postId: 'p1', posts: {}, images: [], scheduledTime: '2025-01-01T00:00:00Z' };
const result = await publishSocialMediaPost(args);
expect(apiFetchMock).toHaveBeenCalledWith('https://api.test/socialmedia/publish_post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
expect(result).toEqual({ status: 'sucess' });
});
it('generateSocialMediaPosts returns null on non-ok', async () => {
apiFetchMock.mockResolvedValueOnce(status(500));
const res = await generateSocialMediaPosts({});
expect(res).toBeNull();
});
});

View File

@@ -0,0 +1,46 @@
import { getWebhookUrl } from '@/lib/config';
export const generateSocialMediaPosts = async (payload: any): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/socialmedia/post2event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error generating social media posts:', error);
return null;
}
};
export const publishSocialMediaPost = async ({ postId, posts, images, scheduledTime }: {
postId: string;
posts: any;
images: any;
scheduledTime: string;
}): Promise<any> => {
try {
const { apiFetch } = await import('@/utils/apiFetch');
const response = await apiFetch(`${getWebhookUrl()}/socialmedia/publish_post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ postId, posts, images, scheduledTime }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error publishing social media post:', error);
return null;
}
};

View File

@@ -0,0 +1,482 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { uploadEventFile } from '@/shared/api/fileService';
import { getEventMedia } from '@/features/event/api/getEventMedia';
import { generateSocialMediaPosts, publishSocialMediaPost } from '@/features/photopost/api/socialPostService';
import { speechToText } from '@/shared/api/toolService';
import { Loader2, Check, X } from 'lucide-react';
interface PhotoPostWizardProps {
eventId: string | number;
onComplete: () => void;
images?: { url: string; id: string }[];
participants?: { id: string; name: string; avatar?: string }[];
onFetchEventImages?: (eventId: string | number) => Promise<{ id: string; url: string }[]>;
onUploadEventFile?: (file: File, eventId?: string) => Promise<{ success: boolean; url?: string; fileUrl?: string; id?: string; message?: string }>;
onGenerateSocialPosts?: (payload: any) => Promise<any>;
onPublishSocialPost?: (args: { postId: string; posts: any; images: any; scheduledTime: string }) => Promise<any>;
onSpeechToText?: (file: File) => Promise<{ success: boolean; transcription?: string; message: string }>;
}
type Step = 'media' | 'loading' | 'review';
const defaultChannels = ['LinkedIn', 'Twitter', 'Facebook', 'Instagram'];
const MicIcon: React.FC<{ className?: string }> = ({ className = 'w-5 h-5 sm:w-6 sm:h-6' }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 1.75a3.25 3.25 0 013.25 3.25v6a3.25 3.25 0 11-6.5 0v-6A3.25 3.25 0 0112 1.75z" />
<path d="M5.75 11.75a6.25 6.25 0 0012.5 0" />
<path d="M12 18v4" />
<path d="M8 22h8" />
</svg>
);
const PhotoPostWizard: React.FC<PhotoPostWizardProps> = ({ eventId, onComplete, images = [], participants = [], onFetchEventImages, onUploadEventFile, onGenerateSocialPosts, onPublishSocialPost, onSpeechToText }) => {
const [step, setStep] = useState<Step>('media');
const [eventImages, setEventImages] = useState<{ id: string; url: string }[]>(images);
const [localImages, setLocalImages] = useState(images);
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [description, setDescription] = useState('');
const [selectedParticipants, setSelectedParticipants] = useState<string[]>([]);
const [selectedChannels, setSelectedChannels] = useState<string[]>(defaultChannels);
const [loading, setLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [scheduledTime, setScheduledTime] = useState('');
const [aiPosts, setAiPosts] = useState<Record<string, Record<string, string>> | null>(null);
const [editedPosts, setEditedPosts] = useState<Record<string, Record<string, string>>>({});
const [activeLang, setActiveLang] = useState<'de' | 'en' | 'es'>('de');
const [postId, setPostId] = useState<string>('');
const languages = [
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'Englisch' },
{ code: 'es', label: 'Spanisch' },
];
// voice recording
const [recording, setRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunks = useRef<Blob[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const handleImageClick = (e: React.MouseEvent, url: string) => {
e.stopPropagation();
setPreviewImage(url);
};
const handleSelectImage = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedImageIds((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]));
};
const canContinueFromMedia = selectedImageIds.length > 0 && description.trim().length > 0;
const selectedImages = useMemo(() => [...eventImages, ...localImages].filter((img) => selectedImageIds.includes(img.id)), [eventImages, localImages, selectedImageIds]);
const toggleArrayValue = (arr: string[], value: string) => (arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]);
// fetch event images on mount
useEffect(() => {
const fetchEventImages = async () => {
try {
const imgs = onFetchEventImages
? await onFetchEventImages(eventId)
: await getEventMedia(eventId as any);
if (imgs) {
const normalized = Array.isArray(imgs)
? imgs.map((item: any, idx: number) =>
typeof item === 'string'
? { id: `remote-${idx}`, url: item }
: { id: item.id?.toString() || `remote-${idx}`, url: item.url || item.file_url }
)
: imgs;
setEventImages(normalized);
}
} catch (err) {
console.error('Fehler beim Laden der Event Bilder', err);
}
};
fetchEventImages();
}, [eventId, onFetchEventImages]);
useEffect(() => {
if (!images) return;
setLocalImages((prev) => {
const map = new Map<string, { id: string; url: string }>();
for (const it of prev) map.set(it.id, it);
for (const it of images) map.set(it.id, it);
const merged = Array.from(map.values());
if (
merged.length === prev.length &&
merged.every((item, idx) => item.id === prev[idx].id && item.url === prev[idx].url)
) {
return prev;
}
return merged;
});
}, [images]);
// recording handlers
const handleStartRecording = async () => {
setAudioBlob(null);
setIsProcessing(false);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunks.current = [];
mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) audioChunks.current.push(event.data); };
mediaRecorder.onstop = async () => {
setIsProcessing(true);
const blob = new Blob(audioChunks.current, { type: 'audio/webm' });
setAudioBlob(blob);
try {
const file = new File([blob], `voice_${Date.now()}.webm`, { type: 'audio/webm' });
const sttResult = onSpeechToText ? await onSpeechToText(file) : await speechToText(file);
if (sttResult.success && sttResult.transcription) {
setDescription((prev) => (prev ? `${prev} ${sttResult.transcription}` : sttResult.transcription));
}
} catch (error) {
console.error('Error converting speech to text:', error);
} finally {
setIsProcessing(false);
}
};
mediaRecorder.start();
setRecording(true);
} catch (e) {
console.error('Error accessing microphone:', e);
alert('Konnte nicht auf das Mikrofon zugreifen.');
}
};
const handleStopRecording = () => {
if (mediaRecorderRef.current && recording) {
mediaRecorderRef.current.stop();
setRecording(false);
}
};
useEffect(() => {
return () => {
if (audioBlob) {
const url = URL.createObjectURL(audioBlob);
URL.revokeObjectURL(url);
}
};
}, [audioBlob]);
const handleUploadClick = () => fileInputRef.current?.click();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (!file.type.startsWith('image/')) { alert('Nur Bilddateien erlaubt.'); return; }
if (file.size === 0) { alert('Datei ist leer (0 Bytes).'); return; }
const result = onUploadEventFile
? await onUploadEventFile(file, String(eventId))
: await uploadEventFile(file, String(eventId));
if (result.success && (result.url || result.fileUrl)) {
const imgUrl = result.url || result.fileUrl;
const uploaded = { id: result.id || `up-${Date.now()}`, url: imgUrl } as { id: string; url: string };
setLocalImages((prev) => [...prev, uploaded]);
setSelectedImageIds((prev) => [...prev, uploaded.id]);
}
}
e.target.value = '';
};
const handleGenerate = async () => {
setLoading(true);
setAiPosts(null);
try {
const payload: any = {
eventId,
images: selectedImageIds
.map((id) => [...eventImages, ...localImages].find((img) => img.id === id)?.id)
.filter(Boolean),
description,
participants: selectedParticipants
.map((id) => participants.find((p) => p.id === id)?.name)
.filter(Boolean),
channels: defaultChannels,
};
const response = onGenerateSocialPosts
? await onGenerateSocialPosts(payload)
: await generateSocialMediaPosts(payload);
const obj = Array.isArray(response) ? response[0] : response;
const src = obj?.output ? obj.output : obj;
const result: Record<string, Record<string, string>> = { de: {}, en: {}, es: {} };
for (const channel of defaultChannels) {
const key = channel.toLowerCase();
const arr = src?.[key];
if (Array.isArray(arr) && arr[0]?.text && arr[0].text[0]) {
result.de[key] = arr[0].text[0].de || '';
result.en[key] = arr[0].text[0].en || '';
result.es[key] = arr[0].text[0].es || '';
} else {
result.de[key] = '';
result.en[key] = '';
result.es[key] = '';
}
}
setPostId(obj?.id || obj?.postId || obj?.data?.id || obj?.data?.postId || '');
setAiPosts(result);
setEditedPosts(result);
setStep('review');
} catch (err) {
console.error('Error generating posts', err);
setStep('media');
} finally {
setLoading(false);
}
};
const handleImmediatePost = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
if (!postId) { return; }
const result = onPublishSocialPost
? await onPublishSocialPost({ postId, posts: editedPosts, images: selectedImageIds, scheduledTime: new Date().toISOString() })
: await publishSocialMediaPost({ postId, posts: editedPosts, images: selectedImageIds, scheduledTime: new Date().toISOString() });
if (result?.status === 'sucess') {
onComplete();
}
} finally {
setIsSubmitting(false);
}
};
const handleScheduledPost = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
if (!scheduledTime) { return; }
if (!postId) { return; }
const result = onPublishSocialPost
? await onPublishSocialPost({ postId, posts: editedPosts, images: selectedImageIds, scheduledTime })
: await publishSocialMediaPost({ postId, posts: editedPosts, images: selectedImageIds, scheduledTime });
if (result?.status === 'sucess') {
onComplete();
}
} finally {
setIsSubmitting(false);
}
};
return (
<>
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle>Quick Post für Event #{String(eventId)}</CardTitle>
</CardHeader>
<CardContent>
{step === 'media' && (
<div className="space-y-4">
<div className="flex justify-between items-center mb-2">
<div className="font-semibold">Bilder aus diesem Event</div>
{[...eventImages, ...localImages].length > 0 && (
<span className="text-sm text-muted-foreground">{selectedImageIds.length} {selectedImageIds.length === 1 ? 'Bild' : 'Bilder'} ausgewählt</span>
)}
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-3">
{eventImages.length > 0 ? (
eventImages.map((img) => {
const isSelected = selectedImageIds.includes(img.id);
return (
<div
key={img.id}
onClick={(e) => handleImageClick(e, img.url)}
className={`relative group rounded-md overflow-hidden border-2 cursor-pointer transition-all ${
isSelected ? 'border-primary ring-2 ring-primary' : 'border-transparent hover:border-muted-foreground/30'
} aspect-square`}
>
<img src={img.url} alt="Event" className="absolute inset-0 w-full h-full object-cover" />
<div
onClick={(e) => handleSelectImage(img.id, e)}
className={`absolute top-1 right-1 w-5 h-5 rounded flex items-center justify-center transition-colors ${
isSelected ? 'bg-primary text-primary-foreground' : 'bg-background/90 text-foreground/50 hover:bg-background/80 hover:text-foreground/70'
}`}
>
{isSelected && <Check className="h-3.5 w-3.5" />}
</div>
</div>
);
})
) : (
<div className="col-span-full text-center py-4 text-sm text-muted-foreground">Keine Bilder in diesem Event vorhanden.</div>
)}
</div>
<div className="font-semibold mb-1">Bilder aus dieser Session</div>
<div className="flex flex-wrap gap-2 mb-2 items-center">
<Button type="button" variant="outline" size="sm" onClick={handleUploadClick} className="h-16 w-16 flex items-center justify-center p-0 text-xl" title="Bild hochladen"></Button>
<input type="file" accept="image/*" ref={fileInputRef} style={{ display: 'none' }} onChange={handleFileChange} />
{localImages.length === 0 && (
<span className="text-xs text-muted-foreground">Keine Bilder in dieser Session.</span>
)}
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 flex-1 min-w-full">
{localImages.map((img) => {
const isSelected = selectedImageIds.includes(img.id);
return (
<div
key={img.id}
onClick={(e) => handleImageClick(e, img.url)}
className={`relative group rounded-md overflow-hidden border-2 cursor-pointer transition-all ${
isSelected ? 'border-primary ring-2 ring-primary' : 'border-transparent hover:border-muted-foreground/30'
} aspect-square`}
>
<img src={img.url} alt="Session" className="absolute inset-0 w-full h-full object-cover" />
<div
onClick={(e) => handleSelectImage(img.id, e)}
className={`absolute top-1 right-1 w-5 h-5 rounded flex items-center justify-center transition-colors ${
isSelected ? 'bg-primary text-primary-foreground' : 'bg-background/90 text-foreground/50 hover:bg-background/80 hover:text-foreground/70'
}`}
>
{isSelected && <Check className="h-3.5 w-3.5" />}
</div>
</div>
);
})}
</div>
</div>
<div className="font-semibold mb-1 mt-2">Wer ist dabei?</div>
<div className="flex flex-wrap gap-2 mb-2">
{participants.map((p) => (
<label key={p.id} className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={selectedParticipants.includes(p.id)} onCheckedChange={() => setSelectedParticipants((prev) => prev.includes(p.id) ? prev.filter((i) => i !== p.id) : [...prev, p.id])} />
<Avatar className="w-6 h-6">{p.avatar ? <AvatarImage src={p.avatar} alt={p.name} /> : <AvatarFallback>{p.name[0]}</AvatarFallback>}</Avatar>
<span className="text-xs">{p.name}</span>
</label>
))}
</div>
<div className="font-semibold mb-1 mt-2">Situation beschreiben</div>
<div className="relative mb-2">
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} placeholder="Was passiert auf dem Foto? Wer ist zu sehen?" disabled={isProcessing} className={`pr-10 ${isProcessing ? 'opacity-60 cursor-not-allowed' : ''}`} />
<button type="button" onClick={recording ? handleStopRecording : handleStartRecording} disabled={isProcessing} className={`absolute bottom-2 right-2 focus:outline-none ${isProcessing ? 'cursor-wait' : 'cursor-pointer'}`} title={recording ? 'Aufnahme stoppen' : 'Aufnahme starten'}>
{isProcessing ? (
<svg className="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
) : recording ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-red-600" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
) : (
<MicIcon />
)}
</button>
</div>
<div className="flex justify-end">
<Button onClick={async () => { setStep('loading'); await handleGenerate(); }} disabled={!canContinueFromMedia || loading}>
Vorschläge generieren
</Button>
</div>
</div>
)}
{step === 'loading' && (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Bitte warten, Beiträge werden generiert...</p>
</div>
)}
{step === 'review' && (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">Vorschau</div>
<div className="rounded-lg border p-4">
<div className="text-sm font-medium mb-2">Beschreibung</div>
<div className="text-sm whitespace-pre-wrap">{description}</div>
{selectedImages.length > 0 && (
<div className="mt-4 flex gap-2 overflow-x-auto">
{selectedImages.map((img) => (
<img key={img.id} src={img.url} className="h-20 rounded border object-cover" />
))}
</div>
)}
</div>
{aiPosts && (
<div className="mt-4">
<div className="font-semibold text-sm sm:text-base mb-2">KI-Vorschläge für Social Media:</div>
<div className="flex gap-2 mb-3">
{languages.map((lang) => (
<button key={lang.code} className={`px-2 py-1 rounded text-xs sm:text-sm transition-colors duration-150 ${activeLang === (lang.code as any) ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`} onClick={() => setActiveLang(lang.code as any)} type="button">
{lang.label}
</button>
))}
</div>
{defaultChannels.map((channel) => (
<div key={channel} className="mb-2">
<div className="text-xs font-bold">{channel}:</div>
<Textarea
value={editedPosts[activeLang]?.[channel.toLowerCase()] || ''}
onChange={(e) =>
setEditedPosts((prev) => ({
...prev,
[activeLang]: {
...(prev[activeLang] || {}),
[channel.toLowerCase()]: e.target.value,
},
}))
}
className="text-xs bg-muted"
rows={2}
/>
</div>
))}
</div>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('media')}>Zurück</Button>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button type="button" className="w-full sm:w-auto" onClick={handleImmediatePost} disabled={isSubmitting}>
Sofort senden
</Button>
<div className="flex gap-2 w-full sm:w-auto">
<input type="datetime-local" value={scheduledTime} onChange={(e) => setScheduledTime(e.target.value)} className="border rounded px-2 py-1 w-full sm:w-auto text-xs" />
<Button variant="outline" className="w-full" onClick={handleScheduledPost} disabled={isSubmitting || !scheduledTime}>
Später posten
</Button>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{previewImage && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-4xl w-full max-h-[90vh]">
<button
className="absolute -top-10 right-0 text-white hover:text-gray-300"
onClick={(e) => {
e.stopPropagation();
setPreviewImage(null);
}}
>
<X className="w-6 h-6" />
</button>
<div className="bg-background rounded-lg overflow-hidden">
<img
src={previewImage}
alt="Vorschau"
className="w-full h-auto max-h-[80vh] object-contain mx-auto"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</div>
)}
</>
);
};
export default PhotoPostWizard;

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PhotoPostWizard from '@/features/photopost/components/PhotoPostWizard';
// Mock event media so presenter doesn't fetch
vi.mock('@/features/event/api/getEventMedia', () => ({
getEventMedia: vi.fn().mockResolvedValue([]),
}));
// Silence console.error noise (optional)
const ce = console.error;
console.error = (...args: any[]) => {};
function makeOutput() {
return {
output: {
linkedin: [{ text: [{ de: 'DE post', en: 'EN post', es: 'ES post' }] }],
twitter: [{ text: [{ de: 'DE tw', en: 'EN tw', es: 'ES tw' }] }],
facebook: [{ text: [{ de: 'DE fb', en: 'EN fb', es: 'ES fb' }] }],
instagram: [{ text: [{ de: 'DE ig', en: 'EN ig', es: 'ES ig' }] }],
},
id: 'post-1',
};
}
describe('PhotoPostWizard presenter - posting flows', () => {
it('generate -> immediate post calls onPublishSocialPost', async () => {
const user = userEvent.setup();
const onGenerateSocialPosts = vi.fn().mockResolvedValue(makeOutput());
const onPublishSocialPost = vi.fn().mockResolvedValue({ status: 'sucess' });
render(
<PhotoPostWizard
eventId={'e1'}
onComplete={() => {}}
images={[{ id: 'img-prop-1', url: 'https://example.com/img.jpg' }]}
onGenerateSocialPosts={onGenerateSocialPosts}
onPublishSocialPost={onPublishSocialPost}
/>
);
// Select image via the select hotspot (absolute control)
const hotspot = document.querySelector('div.absolute.top-1.right-1') as HTMLElement | null;
if (hotspot) {
await user.click(hotspot);
}
// Type description
const desc = screen.getByPlaceholderText(/Was passiert auf dem Foto/i);
await user.type(desc, 'Beschreibung');
// Generate
const genBtn = screen.getByRole('button', { name: /Vorschläge generieren/i });
await user.click(genBtn);
// Review should be visible now, then immediate post
await screen.findAllByRole('textbox');
const immediateBtn = screen.getByRole('button', { name: /Sofort senden/i });
await user.click(immediateBtn);
expect(onPublishSocialPost).toHaveBeenCalledTimes(1);
const callArgs = onPublishSocialPost.mock.calls[0][0];
expect(callArgs).toHaveProperty('postId');
});
it('generate -> scheduled post with datetime', async () => {
const user = userEvent.setup();
const onGenerateSocialPosts = vi.fn().mockResolvedValue(makeOutput());
const onPublishSocialPost = vi.fn().mockResolvedValue({ status: 'sucess' });
render(
<PhotoPostWizard
eventId={'e1'}
onComplete={() => {}}
images={[{ id: 'img-prop-2', url: 'https://example.com/img.jpg' }]}
onGenerateSocialPosts={onGenerateSocialPosts}
onPublishSocialPost={onPublishSocialPost}
/>
);
// Select image via hotspot
const hotspot2 = document.querySelector('div.absolute.top-1.right-1') as HTMLElement | null;
if (hotspot2) {
await user.click(hotspot2);
}
const desc = screen.getByPlaceholderText(/Was passiert auf dem Foto/i);
await user.type(desc, 'Beschreibung');
// Generate
const genBtn = screen.getByRole('button', { name: /Vorschläge generieren/i });
await user.click(genBtn);
// Ensure review is visible, then set datetime and click "Später posten"
await screen.findAllByRole('textbox');
const dtInput = document.querySelector('input[type="datetime-local"]') as HTMLInputElement | null;
const value = '2025-01-01T10:00';
if (dtInput) {
fireEvent.change(dtInput, { target: { value } });
}
const scheduleBtn = await screen.findByRole('button', { name: /Später posten/i });
await user.click(scheduleBtn);
expect(onPublishSocialPost).toHaveBeenCalledTimes(1);
const call = onPublishSocialPost.mock.calls[0][0];
expect(call.scheduledTime).toBe(value);
});
});

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PhotoPostWizard from '@/features/photopost/components/PhotoPostWizard';
// Mock event media fetch to avoid network/JSON parse noise
vi.mock('@/features/event/api/getEventMedia', () => ({
getEventMedia: vi.fn().mockResolvedValue([]),
}));
// Optional: silence console.error noise in this suite
const ce = console.error;
console.error = (...args: any[]) => { /* noop in tests */ };
// restore after suite if needed (here we keep simple)
function makeOutput() {
// minimal shape the component expects
return {
output: {
linkedin: [{ text: [{ de: 'DE post', en: 'EN post', es: 'ES post' }] }],
twitter: [{ text: [{ de: 'DE tw', en: 'EN tw', es: 'ES tw' }] }],
facebook: [{ text: [{ de: 'DE fb', en: 'EN fb', es: 'ES fb' }] }],
instagram: [{ text: [{ de: 'DE ig', en: 'EN ig', es: 'ES ig' }] }],
},
id: 'post-1',
};
}
describe('PhotoPostWizard presenter', () => {
it('selects image, types description and generates suggestions -> review', async () => {
const user = userEvent.setup();
const onGenerateSocialPosts = vi.fn().mockResolvedValue(makeOutput());
// pass one image via props to avoid async fetch
render(
<PhotoPostWizard
eventId={"e1"}
onComplete={() => {}}
images={[{ id: 'img-prop-1', url: 'https://example.com/img.jpg' }]}
onGenerateSocialPosts={onGenerateSocialPosts}
/>
);
// Select image by clicking the small select hotspot (absolute top-right control)
const selectControl = document.querySelector('div.absolute.top-1.right-1') as HTMLElement;
if (selectControl) {
await user.click(selectControl);
}
// Type description
const desc = screen.getByPlaceholderText(/Was passiert auf dem Foto/i);
await user.type(desc, 'Kurze Beschreibung');
// Click "Vorschläge generieren"
const genBtn = screen.getByRole('button', { name: /Vorschläge generieren/i });
await user.click(genBtn);
// After generation, review area should appear with editable textareas
const textareas = await screen.findAllByRole('textbox');
expect(textareas.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,86 @@
import React from 'react';
import PhotoPostWizard from '@/features/photopost/components/PhotoPostWizard';
import { getEventMedia } from '@/features/event/api/getEventMedia';
import { uploadEventFile } from '@/shared/api/fileService';
import { generateSocialMediaPosts, publishSocialMediaPost } from '@/features/photopost/api/socialPostService';
import { speechToText } from '@/shared/api/toolService';
import { toast } from 'sonner';
interface PhotoPostWizardContainerProps {
eventId: string | number;
onComplete: () => void;
}
const PhotoPostWizardContainer: React.FC<PhotoPostWizardContainerProps> = ({ eventId, onComplete }) => {
const onFetchEventImages = async (id: string | number) => {
try {
const images = await getEventMedia(id as any);
return Array.isArray(images) ? images as any : [];
} catch (e) {
toast.error('Fehler beim Laden der Event Bilder');
return [];
}
};
const onUploadEventFile = async (file: File, evId?: string) => {
try {
const res = await uploadEventFile(file, evId);
if (!res.success) toast.error(res.message || 'Upload fehlgeschlagen');
return res;
} catch (e: any) {
toast.error(e?.message || 'Upload fehlgeschlagen');
return { success: false, message: e?.message || 'Upload fehlgeschlagen' };
}
};
const onGenerateSocialPosts = async (payload: any) => {
try {
const res = await generateSocialMediaPosts(payload);
if (!res) throw new Error('Keine Antwort');
return res;
} catch (e) {
toast.error('Fehler beim Generieren');
throw e;
}
};
const onPublishSocialPost = async (args: { postId: string; posts: any; images: any; scheduledTime: string }) => {
try {
const res = await publishSocialMediaPost(args);
if (res?.status === 'sucess') {
toast.success(args.scheduledTime ? 'Erfolgreich geplant!' : 'Erfolgreich veröffentlicht!');
} else {
toast.error('Fehler beim Senden!');
}
return res;
} catch (e) {
toast.error('Fehler beim Senden!');
throw e;
}
};
const onSpeechToTextWrapper = async (file: File) => {
try {
const res = await speechToText(file);
if (!res.success) toast.error(res.message || 'Transkription fehlgeschlagen');
return res;
} catch (e: any) {
toast.error(e?.message || 'Transkription fehlgeschlagen');
return { success: false, message: e?.message || 'Transkription fehlgeschlagen' };
}
};
return (
<PhotoPostWizard
eventId={eventId}
onComplete={onComplete}
onFetchEventImages={onFetchEventImages}
onUploadEventFile={onUploadEventFile}
onGenerateSocialPosts={onGenerateSocialPosts}
onPublishSocialPost={onPublishSocialPost}
onSpeechToText={onSpeechToTextWrapper}
/>
);
};
export default PhotoPostWizardContainer;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import PhotoPostWizardContainer from '@/features/photopost/containers/PhotoPostWizardContainer';
// Mocks for services
const generateSocialMediaPostsMock = vi.fn();
const publishSocialMediaPostMock = vi.fn();
const uploadEventFileMock = vi.fn();
const speechToTextMock = vi.fn();
const getEventMediaMock = vi.fn();
vi.mock('@/features/photopost/api/socialPostService', () => ({
generateSocialMediaPosts: (...args: any[]) => generateSocialMediaPostsMock(...args),
publishSocialMediaPost: (...args: any[]) => publishSocialMediaPostMock(...args),
}));
vi.mock('@/shared/api/fileService', () => ({
uploadEventFile: (...args: any[]) => uploadEventFileMock(...args),
}));
vi.mock('@/shared/api/toolService', () => ({
speechToText: (...args: any[]) => speechToTextMock(...args),
}));
vi.mock('@/features/event/api/getEventMedia', () => ({
getEventMedia: (...args: any[]) => getEventMediaMock(...args),
}));
// Mock presenter to trigger container callbacks
vi.mock('@/features/photopost/components/PhotoPostWizard', () => ({
default: (props: any) => (
<div>
<button
onClick={() => {
const p = props.onGenerateSocialPosts?.({ eventId: 'e1', images: [], description: 'd' });
if (p && typeof p.catch === 'function') p.catch(() => {});
}}
>
trigger-generate
</button>
<button
onClick={() => {
const p = props.onPublishSocialPost?.({ postId: 'p1', posts: {}, images: [], scheduledTime: new Date().toISOString() });
if (p && typeof p.catch === 'function') p.catch(() => {});
}}
>
trigger-publish
</button>
</div>
),
}));
// Hoist-safe sonner mock and import
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { toast } from 'sonner';
beforeEach(() => {
vi.restoreAllMocks();
generateSocialMediaPostsMock.mockReset();
publishSocialMediaPostMock.mockReset();
uploadEventFileMock.mockReset();
speechToTextMock.mockReset();
getEventMediaMock.mockReset();
(toast.success as any).mockReset?.();
(toast.error as any).mockReset?.();
});
describe('PhotoPostWizardContainer (integration)', () => {
it('onPublish success shows success toast', async () => {
publishSocialMediaPostMock.mockResolvedValueOnce({ status: 'sucess' });
render(<PhotoPostWizardContainer eventId={'e1'} onComplete={() => {}} />);
await screen.findByText('trigger-publish');
screen.getByText('trigger-publish').click();
expect(publishSocialMediaPostMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect((toast.success as any)).toHaveBeenCalledTimes(1));
});
it('onPublish error shows error toast', async () => {
publishSocialMediaPostMock.mockRejectedValueOnce(new Error('fail'));
render(<PhotoPostWizardContainer eventId={'e1'} onComplete={() => {}} />);
await screen.findByText('trigger-publish');
screen.getByText('trigger-publish').click();
expect(publishSocialMediaPostMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect((toast.error as any)).toHaveBeenCalledTimes(1));
});
it('onGenerate error shows error toast', async () => {
generateSocialMediaPostsMock.mockRejectedValueOnce(new Error('fail'));
render(<PhotoPostWizardContainer eventId={'e1'} onComplete={() => {}} />);
await screen.findByText('trigger-generate');
screen.getByText('trigger-generate').click();
expect(generateSocialMediaPostsMock).toHaveBeenCalledTimes(1);
await waitFor(() => expect((toast.error as any)).toHaveBeenCalledTimes(1));
});
});

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PhotoPostWizardContainer from '@/features/photopost/containers/PhotoPostWizardContainer';
import { type DashboardModule } from '@/components/dashboard/dashboardModuleTypes';
const defaultEventImage = '/default_event.png';
export const quickPostDashboardModule: DashboardModule = {
id: 'quick-post',
getTitle: (ctx) => (ctx.currentEvent as any)?.title || ctx.currentEvent?.name || 'Aktuelles Event',
getDescription: (ctx) => ctx.currentEvent?.description || 'Aktuelles Event',
getIllustration: (ctx) => {
const rawImage = (ctx.currentEvent as any)?.coverMediaUrl || (ctx.currentEvent as any)?.image;
const hasImage = rawImage && String(rawImage).trim() !== '';
return hasImage ? String(rawImage) : defaultEventImage;
},
isVisible: (ctx) => {
const ev: any = ctx.currentEvent;
if (!ev) return false;
const now = new Date();
now.setHours(0, 0, 0, 0);
const dateStr = ev.eventDate || ev.event_date || ev.begin_date;
if (!dateStr) return false;
const eventDate = new Date(dateStr);
if (Number.isNaN(eventDate.getTime())) return false;
eventDate.setHours(0, 0, 0, 0);
// Karte nur anzeigen, wenn das Event heute stattfindet
return eventDate.getTime() === now.getTime();
},
renderWizard: (ctx, onBack) => {
const ev: any = ctx.currentEvent;
const eventId = ev?.id ?? ev?._id ?? ev?.uuid ?? '';
return (
<div className="space-y-6">
<Button
variant="ghost"
className="mb-4"
onClick={onBack}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück zur Übersicht
</Button>
<PhotoPostWizardContainer eventId={eventId} onComplete={onBack} />
</div>
);
},
};

View File

View File

View File

19
src/hooks/use-mobile.tsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

Some files were not shown because too many files have changed in this diff Show More