first commit
This commit is contained in:
42
src/App.css
Normal file
42
src/App.css
Normal 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
48
src/App.tsx
Normal 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
108
src/components/app/Main.tsx
Normal 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;
|
||||
43
src/components/auth/LoginContainer.tsx
Normal file
43
src/components/auth/LoginContainer.tsx
Normal 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;
|
||||
75
src/components/auth/LoginForm.tsx
Normal file
75
src/components/auth/LoginForm.tsx
Normal 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;
|
||||
108
src/components/dashboard/Dashboard.tsx
Normal file
108
src/components/dashboard/Dashboard.tsx
Normal 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
|
||||
|
||||
43
src/components/dashboard/DashboardContainer.tsx
Normal file
43
src/components/dashboard/DashboardContainer.tsx
Normal 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;
|
||||
15
src/components/dashboard/dashboardModuleTypes.ts
Normal file
15
src/components/dashboard/dashboardModuleTypes.ts
Normal 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;
|
||||
}
|
||||
56
src/components/ui/accordion.tsx
Normal file
56
src/components/ui/accordion.tsx
Normal 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 }
|
||||
139
src/components/ui/alert-dialog.tsx
Normal file
139
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
5
src/components/ui/aspect-ratio.tsx
Normal file
5
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal 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 }
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 }
|
||||
61
src/components/ui/calendar.tsx
Normal file
61
src/components/ui/calendar.tsx
Normal 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 };
|
||||
80
src/components/ui/card.tsx
Normal file
80
src/components/ui/card.tsx
Normal 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 }
|
||||
260
src/components/ui/carousel.tsx
Normal file
260
src/components/ui/carousel.tsx
Normal 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
363
src/components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal 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 }
|
||||
153
src/components/ui/command.tsx
Normal file
153
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
198
src/components/ui/context-menu.tsx
Normal file
198
src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
116
src/components/ui/drawer.tsx
Normal file
116
src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal 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
176
src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal 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 }
|
||||
69
src/components/ui/input-otp.tsx
Normal file
69
src/components/ui/input-otp.tsx
Normal 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 }
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
234
src/components/ui/menubar.tsx
Normal file
234
src/components/ui/menubar.tsx
Normal 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,
|
||||
}
|
||||
128
src/components/ui/navigation-menu.tsx
Normal file
128
src/components/ui/navigation-menu.tsx
Normal 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,
|
||||
}
|
||||
117
src/components/ui/pagination.tsx
Normal file
117
src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal 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 }
|
||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal 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 }
|
||||
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal 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 }
|
||||
43
src/components/ui/resizable.tsx
Normal file
43
src/components/ui/resizable.tsx
Normal 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 }
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal 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
131
src/components/ui/sheet.tsx
Normal 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
|
||||
}
|
||||
|
||||
761
src/components/ui/sidebar.tsx
Normal file
761
src/components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal 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 }
|
||||
29
src/components/ui/sonner.tsx
Normal file
29
src/components/ui/sonner.tsx
Normal 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 }
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal 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
117
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal 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 }
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal 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
127
src/components/ui/toast.tsx
Normal 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,
|
||||
}
|
||||
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/toggle-group.tsx
Normal file
59
src/components/ui/toggle-group.tsx
Normal 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 }
|
||||
43
src/components/ui/toggle.tsx
Normal file
43
src/components/ui/toggle.tsx
Normal 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 }
|
||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
3
src/components/ui/use-toast.ts
Normal file
3
src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { useToast, toast } from "@/hooks/use-toast";
|
||||
|
||||
export { useToast, toast };
|
||||
91
src/contexts/AuthContext.tsx
Normal file
91
src/contexts/AuthContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
614
src/contexts/LanguageContext.tsx
Normal file
614
src/contexts/LanguageContext.tsx
Normal 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;
|
||||
};
|
||||
159
src/features/blog/api/blogService.ts
Normal file
159
src/features/blog/api/blogService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
0
src/features/blog/components/.gitkeep
Normal file
0
src/features/blog/components/.gitkeep
Normal file
604
src/features/blog/components/BlogWizard.tsx
Normal file
604
src/features/blog/components/BlogWizard.tsx
Normal 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;
|
||||
36
src/features/blog/components/LanguageTabs.tsx
Normal file
36
src/features/blog/components/LanguageTabs.tsx
Normal 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;
|
||||
152
src/features/blog/components/SocialSchedulingPanel.tsx
Normal file
152
src/features/blog/components/SocialSchedulingPanel.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
0
src/features/blog/containers/.gitkeep
Normal file
0
src/features/blog/containers/.gitkeep
Normal file
103
src/features/blog/containers/BlogWizardContainer.tsx
Normal file
103
src/features/blog/containers/BlogWizardContainer.tsx
Normal 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;
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
25
src/features/blog/dashboardModule.tsx
Normal file
25
src/features/blog/dashboardModule.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
0
src/features/blog/forms/.gitkeep
Normal file
0
src/features/blog/forms/.gitkeep
Normal file
0
src/features/blog/schemas/.gitkeep
Normal file
0
src/features/blog/schemas/.gitkeep
Normal file
0
src/features/blog/types/.gitkeep
Normal file
0
src/features/blog/types/.gitkeep
Normal file
72
src/features/event/api/__tests__/eventService.api.test.ts
Normal file
72
src/features/event/api/__tests__/eventService.api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
13
src/features/event/api/__tests__/eventService.test.ts
Normal file
13
src/features/event/api/__tests__/eventService.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
435
src/features/event/api/eventService.ts
Normal file
435
src/features/event/api/eventService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
51
src/features/event/api/getEventMedia.ts
Normal file
51
src/features/event/api/getEventMedia.ts
Normal 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 [];
|
||||
}
|
||||
};
|
||||
320
src/features/event/components/ArtistCollabForm.tsx
Normal file
320
src/features/event/components/ArtistCollabForm.tsx
Normal 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;
|
||||
1177
src/features/event/components/EventFormInline.tsx
Normal file
1177
src/features/event/components/EventFormInline.tsx
Normal file
File diff suppressed because it is too large
Load Diff
189
src/features/event/components/EventWizard.tsx
Normal file
189
src/features/event/components/EventWizard.tsx
Normal 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;
|
||||
51
src/features/event/components/__tests__/EventWizard.test.tsx
Normal file
51
src/features/event/components/__tests__/EventWizard.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
43
src/features/event/containers/EventWizardContainer.tsx
Normal file
43
src/features/event/containers/EventWizardContainer.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/features/event/dashboardModule.tsx
Normal file
25
src/features/event/dashboardModule.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
0
src/features/photopost/api/.gitkeep
Normal file
0
src/features/photopost/api/.gitkeep
Normal 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();
|
||||
});
|
||||
});
|
||||
46
src/features/photopost/api/socialPostService.ts
Normal file
46
src/features/photopost/api/socialPostService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
0
src/features/photopost/components/.gitkeep
Normal file
0
src/features/photopost/components/.gitkeep
Normal file
482
src/features/photopost/components/PhotoPostWizard.tsx
Normal file
482
src/features/photopost/components/PhotoPostWizard.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
0
src/features/photopost/containers/.gitkeep
Normal file
0
src/features/photopost/containers/.gitkeep
Normal 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;
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
52
src/features/photopost/dashboardModule.tsx
Normal file
52
src/features/photopost/dashboardModule.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
0
src/features/photopost/forms/.gitkeep
Normal file
0
src/features/photopost/forms/.gitkeep
Normal file
0
src/features/photopost/schemas/.gitkeep
Normal file
0
src/features/photopost/schemas/.gitkeep
Normal file
0
src/features/photopost/types/.gitkeep
Normal file
0
src/features/photopost/types/.gitkeep
Normal file
19
src/hooks/use-mobile.tsx
Normal file
19
src/hooks/use-mobile.tsx
Normal 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
Reference in New Issue
Block a user