new desighn #deploy
This commit is contained in:
parent
98b8abd56c
commit
9067a4c6c0
248
src/features/event/components/ArtistsSection.tsx
Normal file
248
src/features/event/components/ArtistsSection.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type ArtistSection = {
|
||||
name: string;
|
||||
email: string;
|
||||
link: string;
|
||||
status: 'Wartet auf Künstler' | 'Link generiert' | 'Fertig' | 'Künstler einladen';
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
eventId?: string | number | null;
|
||||
|
||||
artistSections: ArtistSection[];
|
||||
setArtistSections: React.Dispatch<React.SetStateAction<ArtistSection[]>>;
|
||||
|
||||
sectionDates: string[];
|
||||
setSectionDates: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
sectionDateRefs: React.MutableRefObject<Array<HTMLInputElement | null>>;
|
||||
|
||||
promotionSlots: any[] | null;
|
||||
|
||||
generatingLinkIndex: number | null;
|
||||
setGeneratingLinkIndex: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
|
||||
generateCollabLink: (eventId: string | number, slotIndex: number, artistName: string, artistEmail: string) => Promise<any>;
|
||||
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
formatDateForDisplay: (iso: string) => string;
|
||||
|
||||
onOpenPreview: (slot: any) => void;
|
||||
};
|
||||
|
||||
const ArtistsSection: React.FC<Props> = ({
|
||||
mode,
|
||||
eventId,
|
||||
artistSections,
|
||||
setArtistSections,
|
||||
sectionDates,
|
||||
setSectionDates,
|
||||
sectionDateRefs,
|
||||
promotionSlots,
|
||||
generatingLinkIndex,
|
||||
setGeneratingLinkIndex,
|
||||
generateCollabLink,
|
||||
setError,
|
||||
formatDateForDisplay,
|
||||
onOpenPreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{artistSections.map((section, idx) => {
|
||||
const slot = promotionSlots?.find((s) => s.slotIndex === idx + 2);
|
||||
const hasArtistData = !!slot?.artistDescription || (Array.isArray(slot?.media) && slot.media.length > 0);
|
||||
const canOpenPreview = mode === 'edit' && !!slot && hasArtistData;
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<span>{`Künstler ${idx + 1}`}</span>
|
||||
<span className="text-xs text-muted-foreground">{artistSections[idx].status}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDateForDisplay(sectionDates[idx + 1]) || 'Noch kein Datum gewählt'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ref = sectionDateRefs.current[idx + 1];
|
||||
if (ref) {
|
||||
if (ref.showPicker) {
|
||||
ref.showPicker();
|
||||
} else {
|
||||
ref.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Datum wählen
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
ref={(el) => (sectionDateRefs.current[idx + 1] = el)}
|
||||
id={`section_date_${idx + 2}`}
|
||||
type="date"
|
||||
className="hidden"
|
||||
value={sectionDates[idx + 1]}
|
||||
onChange={(e) => {
|
||||
const next = [...sectionDates];
|
||||
next[idx + 1] = e.target.value;
|
||||
setSectionDates(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`artist_name_${idx}`}>Künstlername *</Label>
|
||||
<Input
|
||||
id={`artist_name_${idx}`}
|
||||
type="text"
|
||||
placeholder="Name des Künstlers"
|
||||
value={section.name}
|
||||
onChange={(e) => {
|
||||
const next = [...artistSections];
|
||||
next[idx] = { ...next[idx], name: e.target.value };
|
||||
setArtistSections(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-muted-foreground">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (mode !== 'edit' || !eventId) {
|
||||
setError('Bitte Event zuerst speichern, bevor Links generiert werden.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setGeneratingLinkIndex(idx);
|
||||
const raw = await generateCollabLink(eventId, idx + 2, section.name, section.email || '');
|
||||
|
||||
const res: 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;
|
||||
})();
|
||||
|
||||
const link = res?.url || (res?.token ? `/collab/${res.token}` : '');
|
||||
if (!link) {
|
||||
setError('Kollaborationslink konnte nicht erzeugt werden.');
|
||||
return;
|
||||
}
|
||||
const next = [...artistSections];
|
||||
next[idx] = { ...next[idx], link, status: 'Link generiert' };
|
||||
setArtistSections(next);
|
||||
} catch (e) {
|
||||
setError('Kollaborationslink konnte nicht erzeugt werden.');
|
||||
} finally {
|
||||
setGeneratingLinkIndex((current) => (current === idx ? null : current));
|
||||
}
|
||||
}}
|
||||
disabled={!section.name || !eventId || mode !== 'edit' || generatingLinkIndex === idx}
|
||||
>
|
||||
{generatingLinkIndex === idx && (
|
||||
<span className="h-3 w-3 mr-1 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
)}
|
||||
Link generieren
|
||||
</Button>
|
||||
|
||||
{section.link && (
|
||||
<div className="flex flex-1 items-center gap-2 w-full">
|
||||
<Input readOnly className="text-xs flex-1" value={section.link} onFocus={(e) => e.target.select()} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(section.link || '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Clipboard copy failed', e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Kopieren
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<Button type="button" variant="outline" size="sm" disabled={!canOpenPreview} onClick={() => slot && onOpenPreview(slot)}>
|
||||
Künstlerdaten ansehen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setArtistSections((prev) => {
|
||||
const next = [
|
||||
...prev,
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
link: '',
|
||||
status: 'Wartet auf Künstler' as const,
|
||||
},
|
||||
];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Künstler hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={artistSections.length <= 1}
|
||||
onClick={() => {
|
||||
if (artistSections.length <= 1) return;
|
||||
setArtistSections((prev) => prev.slice(0, -1));
|
||||
setSectionDates((prev) => {
|
||||
if (prev.length <= 2) return prev;
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Letzten Künstler entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtistsSection;
|
||||
@ -4,8 +4,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { CalendarDays, Megaphone, Users } from 'lucide-react';
|
||||
import { createEvent, fetchEventById, updateEvent, getEventInfoFromUrl, generateCollabLink, updateArtistCollabDataByOp } from '@/features/event/api/eventService';
|
||||
import { useApiFetch } from '@/utils/apiFetch';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import EventStammdatenSection from '@/features/event/components/EventStammdatenSection';
|
||||
import PromotionTemplatesSection from '@/features/event/components/PromotionTemplatesSection';
|
||||
import ArtistsSection from '@/features/event/components/ArtistsSection';
|
||||
|
||||
interface EventFormInlineProps {
|
||||
onSuccess: () => void;
|
||||
@ -55,6 +60,9 @@ const EventFormInline: React.FC<EventFormInlineProps> = ({ onSuccess, onCancel,
|
||||
const [extractingFromUrl, setExtractingFromUrl] = useState(false);
|
||||
const [promotionOffsetDays, setPromotionOffsetDays] = useState<string>('2');
|
||||
const [generatingLinkIndex, setGeneratingLinkIndex] = useState<number | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [activeTab, setActiveTab] = useState<'stammdaten' | 'promotion' | 'artists'>('stammdaten');
|
||||
const navExpanded = !isMobile;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -469,427 +477,155 @@ const EventFormInline: React.FC<EventFormInlineProps> = ({ onSuccess, onCancel,
|
||||
return fields.some((key) => (previewDraft as any)[key] !== (previewSlot as any)[key]);
|
||||
})();
|
||||
|
||||
const sidebarWidthClass = navExpanded ? 'w-56' : 'w-14';
|
||||
|
||||
const NavItemButton = ({
|
||||
tab,
|
||||
label,
|
||||
Icon,
|
||||
}: {
|
||||
tab: 'stammdaten' | 'promotion' | 'artists';
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
}) => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={
|
||||
navExpanded
|
||||
? `relative w-full h-10 justify-start gap-3 rounded-xl px-3 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary shadow-sm'
|
||||
: 'text-foreground/80 hover:bg-primary/5 hover:text-foreground'
|
||||
}`
|
||||
: `relative w-10 h-10 justify-center rounded-xl px-0 transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary shadow-sm'
|
||||
: 'text-foreground/80 hover:bg-primary/5 hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
navExpanded
|
||||
? `absolute left-0 top-1/2 -translate-y-1/2 h-6 w-1 rounded-r ${isActive ? 'bg-primary' : 'bg-transparent'}`
|
||||
: 'hidden'
|
||||
}
|
||||
/>
|
||||
<Icon className="h-4 w-4" />
|
||||
{navExpanded && <span className="truncate">{label}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Event-URL ganz oben */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ticketUrl">Event-URL</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center">
|
||||
<Input
|
||||
id="ticketUrl"
|
||||
name="ticketUrl"
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
value={form.ticketUrl}
|
||||
onChange={handleInput}
|
||||
readOnly={mode === 'edit'}
|
||||
/>
|
||||
{mode === 'create' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0 flex items-center gap-2"
|
||||
onClick={handleExtractFromUrl}
|
||||
disabled={!form.ticketUrl || extractingFromUrl}
|
||||
>
|
||||
{extractingFromUrl && (
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
)}
|
||||
<span>{extractingFromUrl ? 'Lade…' : 'Daten aus URL laden'}</span>
|
||||
</Button>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="sticky top-0 z-20 -mx-4 px-4 py-2 border-b bg-background">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{mode === 'edit' ? 'Event bearbeiten' : 'Event erstellen'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{loadingInitial ? 'Lade…' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="text-sm text-destructive pt-1">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input id="name" name="name" value={form.name} onChange={handleInput} required />
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<aside className={`sticky top-20 self-start shrink-0 ${sidebarWidthClass} transition-[width] duration-200`}>
|
||||
<div className="rounded-2xl border bg-background p-2 shadow-sm">
|
||||
{navExpanded && <div className="text-xs text-muted-foreground px-2 pb-2">Menü</div>}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Beschreibung *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={handleInput}
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="begin_date">Datum *</Label>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDateForDisplay(form.begin_date) || 'Noch kein Datum gewählt'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (beginDateRef.current) {
|
||||
if (beginDateRef.current.showPicker) {
|
||||
beginDateRef.current.showPicker();
|
||||
} else {
|
||||
beginDateRef.current.focus();
|
||||
}
|
||||
}
|
||||
<NavItemButton tab="stammdaten" label="Stammdaten" Icon={CalendarDays} />
|
||||
<NavItemButton tab="promotion" label="Promotion" Icon={Megaphone} />
|
||||
<NavItemButton tab="artists" label="Künstler" Icon={Users} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{activeTab === 'stammdaten' && (
|
||||
<EventStammdatenSection
|
||||
form={form}
|
||||
mode={mode}
|
||||
extractingFromUrl={extractingFromUrl}
|
||||
onExtractFromUrl={handleExtractFromUrl}
|
||||
onInput={handleInput}
|
||||
beginDateRef={beginDateRef}
|
||||
setForm={setForm}
|
||||
formatDateForDisplay={formatDateForDisplay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'promotion' && (
|
||||
<PromotionTemplatesSection
|
||||
promotionOffsetDays={promotionOffsetDays}
|
||||
setPromotionOffsetDays={setPromotionOffsetDays}
|
||||
promotionStartDate={promotionStartDate}
|
||||
handlePromotionStartChange={handlePromotionStartChange}
|
||||
promotionStartRef={promotionStartRef}
|
||||
formatDateForDisplay={formatDateForDisplay}
|
||||
mediaUrl={form.mediaUrl}
|
||||
onMediaUrlChange={(url) => {
|
||||
setForm((prev) => ({ ...prev, mediaUrl: url }));
|
||||
setMediaPreview(url || null);
|
||||
}}
|
||||
>
|
||||
Datum wählen
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
ref={beginDateRef}
|
||||
id="begin_date"
|
||||
name="begin_date"
|
||||
type="date"
|
||||
className="hidden"
|
||||
value={form.begin_date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, begin_date: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admissionTimes">Einlasszeiten *</Label>
|
||||
<Input
|
||||
id="admissionTimes"
|
||||
name="admissionTimes"
|
||||
type="text"
|
||||
placeholder="z.B. Einlass 19:00, Beginn 20:00"
|
||||
value={form.admissionTimes}
|
||||
onChange={handleInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ticketPrice">Ticketpreis *</Label>
|
||||
<Input
|
||||
id="ticketPrice"
|
||||
name="ticketPrice"
|
||||
type="text"
|
||||
placeholder="z.B. 10 € / 8 € erm."
|
||||
value={form.ticketPrice}
|
||||
onChange={handleInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Promotion</Label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Letzter Post vor Event</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-7 w-16 text-xs"
|
||||
value={promotionOffsetDays}
|
||||
onChange={(e) => setPromotionOffsetDays(e.target.value)}
|
||||
/>
|
||||
<span>Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sektion 1: Event-Promotion (nur Datum) */}
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-sm font-medium">Sektion 1 – Event-Promotion</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDateForDisplay(promotionStartDate) || 'Noch kein Datum gewählt'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (promotionStartRef.current) {
|
||||
if (promotionStartRef.current.showPicker) {
|
||||
promotionStartRef.current.showPicker();
|
||||
} else {
|
||||
promotionStartRef.current.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Datum wählen
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
ref={promotionStartRef}
|
||||
id="promotion_start"
|
||||
name="promotion_start"
|
||||
type="date"
|
||||
className="hidden"
|
||||
value={promotionStartDate}
|
||||
onChange={(e) => handlePromotionStartChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label>Bild / Video</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
|
||||
<Input
|
||||
placeholder="Media-URL (Bild oder Video)"
|
||||
value={form.mediaUrl}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, mediaUrl: e.target.value });
|
||||
setMediaPreview(e.target.value || null);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelected={(file) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setMediaPreview(url);
|
||||
}}
|
||||
mediaPreview={mediaPreview}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
Datei auswählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mediaPreview && (
|
||||
<div className="mt-2">
|
||||
{mediaPreview.match(/\.mp4$|\.webm$|\.ogg$/i) ? (
|
||||
<video src={mediaPreview} className="h-32 rounded border object-cover" controls />
|
||||
) : (
|
||||
<img src={mediaPreview} alt="Event Media" className="h-32 rounded border object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sektion 2–N: Künstler 1–N */}
|
||||
{artistSections.map((section, idx) => {
|
||||
const slot = promotionSlots?.find((s) => s.slotIndex === idx + 2);
|
||||
const hasArtistData = !!slot?.artistDescription || (Array.isArray(slot?.media) && slot.media.length > 0);
|
||||
const canOpenPreview = mode === 'edit' && !!slot && hasArtistData;
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<span>{`Sektion ${idx + 2} – Künstler ${idx + 1}`}</span>
|
||||
<span className="text-xs text-muted-foreground">{artistSections[idx].status}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDateForDisplay(sectionDates[idx + 1]) || 'Noch kein Datum gewählt'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ref = sectionDateRefs.current[idx + 1];
|
||||
if (ref) {
|
||||
if (ref.showPicker) {
|
||||
ref.showPicker();
|
||||
} else {
|
||||
ref.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Datum wählen
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
ref={(el) => (sectionDateRefs.current[idx + 1] = el)}
|
||||
id={`section_date_${idx + 2}`}
|
||||
type="date"
|
||||
className="hidden"
|
||||
value={sectionDates[idx + 1]}
|
||||
onChange={(e) => {
|
||||
const next = [...sectionDates];
|
||||
next[idx + 1] = e.target.value;
|
||||
setSectionDates(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`artist_name_${idx}`}>Künstlername *</Label>
|
||||
<Input
|
||||
id={`artist_name_${idx}`}
|
||||
type="text"
|
||||
placeholder="Name des Künstlers"
|
||||
value={section.name}
|
||||
onChange={(e) => {
|
||||
const next = [...artistSections];
|
||||
next[idx] = { ...next[idx], name: e.target.value };
|
||||
setArtistSections(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-muted-foreground">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (mode !== 'edit' || !eventId) {
|
||||
setError('Bitte Event zuerst speichern, bevor Links generiert werden.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setGeneratingLinkIndex(idx);
|
||||
const raw = await generateCollabLink(eventId, idx + 2, section.name, section.email || '');
|
||||
|
||||
const res: 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;
|
||||
})();
|
||||
|
||||
const link = res?.url || (res?.token ? `/collab/${res.token}` : '');
|
||||
if (!link) {
|
||||
setError('Kollaborationslink konnte nicht erzeugt werden.');
|
||||
return;
|
||||
}
|
||||
const next = [...artistSections];
|
||||
next[idx] = { ...next[idx], link, status: 'Link generiert' };
|
||||
setArtistSections(next);
|
||||
} catch (e) {
|
||||
setError('Kollaborationslink konnte nicht erzeugt werden.');
|
||||
} finally {
|
||||
setGeneratingLinkIndex((current) => (current === idx ? null : current));
|
||||
}
|
||||
}}
|
||||
disabled={!section.name || !eventId || mode !== 'edit' || generatingLinkIndex === idx}
|
||||
>
|
||||
{generatingLinkIndex === idx && (
|
||||
<span className="h-3 w-3 mr-1 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
)}
|
||||
Link generieren
|
||||
</Button>
|
||||
{section.link && (
|
||||
<div className="flex flex-1 items-center gap-2 w-full">
|
||||
<Input
|
||||
readOnly
|
||||
className="text-xs flex-1"
|
||||
value={section.link}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(section.link || '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Clipboard copy failed', e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Kopieren
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canOpenPreview}
|
||||
onClick={() => {
|
||||
if (!slot) return;
|
||||
{activeTab === 'artists' && (
|
||||
<ArtistsSection
|
||||
mode={mode}
|
||||
eventId={eventId}
|
||||
artistSections={artistSections}
|
||||
setArtistSections={setArtistSections}
|
||||
sectionDates={sectionDates}
|
||||
setSectionDates={setSectionDates}
|
||||
sectionDateRefs={sectionDateRefs}
|
||||
promotionSlots={promotionSlots}
|
||||
generatingLinkIndex={generatingLinkIndex}
|
||||
setGeneratingLinkIndex={setGeneratingLinkIndex}
|
||||
generateCollabLink={generateCollabLink}
|
||||
setError={setError}
|
||||
formatDateForDisplay={formatDateForDisplay}
|
||||
onOpenPreview={(slot) => {
|
||||
setPreviewSlot(slot);
|
||||
setPreviewDraft(slot ? { ...slot } : null);
|
||||
setIsPreviewEditing(false);
|
||||
setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
Künstlerdaten ansehen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setArtistSections((prev) => {
|
||||
const next = [
|
||||
...prev,
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
link: '',
|
||||
status: 'Wartet auf Künstler' as 'Wartet auf Künstler' | 'Link generiert' | 'Fertig' | 'Künstler einladen',
|
||||
},
|
||||
];
|
||||
// sectionDates wird über useEffect/recalcPromotionDatesFromEvent anhand der neuen Länge neu berechnet
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Künstler hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={artistSections.length <= 1}
|
||||
onClick={() => {
|
||||
if (artistSections.length <= 1) return;
|
||||
setArtistSections((prev) => prev.slice(0, -1));
|
||||
setSectionDates((prev) => {
|
||||
if (prev.length <= 2) return prev;
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Letzten Künstler entfernen
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<div className="text-xs text-muted-foreground pt-1">* Pflichtfelder</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1" disabled={isSaveDisabled}>
|
||||
Speichern
|
||||
</Button>
|
||||
<div className="sticky bottom-0 z-20 -mx-4 mt-4 px-4 py-3 border-t bg-background">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-muted-foreground">* Pflichtfelder</div>
|
||||
<div className="flex gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={onCancel}>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaveDisabled}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
141
src/features/event/components/EventStammdatenSection.tsx
Normal file
141
src/features/event/components/EventStammdatenSection.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
begin_date: string;
|
||||
mediaUrl: string;
|
||||
ticketUrl: string;
|
||||
admissionTimes: string;
|
||||
ticketPrice: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: FormState;
|
||||
mode: 'create' | 'edit';
|
||||
extractingFromUrl: boolean;
|
||||
onExtractFromUrl: () => void;
|
||||
onInput: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
beginDateRef: React.RefObject<HTMLInputElement | null>;
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
formatDateForDisplay: (iso: string) => string;
|
||||
};
|
||||
|
||||
const EventStammdatenSection: React.FC<Props> = ({
|
||||
form,
|
||||
mode,
|
||||
extractingFromUrl,
|
||||
onExtractFromUrl,
|
||||
onInput,
|
||||
beginDateRef,
|
||||
setForm,
|
||||
formatDateForDisplay,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ticketUrl">Event-URL</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center">
|
||||
<Input
|
||||
id="ticketUrl"
|
||||
name="ticketUrl"
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
value={form.ticketUrl}
|
||||
onChange={onInput}
|
||||
readOnly={mode === 'edit'}
|
||||
/>
|
||||
{mode === 'create' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0 flex items-center gap-2"
|
||||
onClick={onExtractFromUrl}
|
||||
disabled={!form.ticketUrl || extractingFromUrl}
|
||||
>
|
||||
{extractingFromUrl && (
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
)}
|
||||
<span>{extractingFromUrl ? 'Lade…' : 'Daten aus URL laden'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input id="name" name="name" value={form.name} onChange={onInput} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Beschreibung *</Label>
|
||||
<Textarea id="description" name="description" value={form.description} onChange={onInput} rows={6} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="begin_date">Datum *</Label>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDateForDisplay(form.begin_date) || 'Noch kein Datum gewählt'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (beginDateRef.current) {
|
||||
if (beginDateRef.current.showPicker) {
|
||||
beginDateRef.current.showPicker();
|
||||
} else {
|
||||
beginDateRef.current.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Datum wählen
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
ref={beginDateRef}
|
||||
id="begin_date"
|
||||
name="begin_date"
|
||||
type="date"
|
||||
className="hidden"
|
||||
value={form.begin_date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, begin_date: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admissionTimes">Einlasszeiten *</Label>
|
||||
<Input
|
||||
id="admissionTimes"
|
||||
name="admissionTimes"
|
||||
type="text"
|
||||
placeholder="z.B. Einlass 19:00, Beginn 20:00"
|
||||
value={form.admissionTimes}
|
||||
onChange={onInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ticketPrice">Ticketpreis *</Label>
|
||||
<Input
|
||||
id="ticketPrice"
|
||||
name="ticketPrice"
|
||||
type="text"
|
||||
placeholder="z.B. 10 € / 8 € erm."
|
||||
value={form.ticketPrice}
|
||||
onChange={onInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventStammdatenSection;
|
||||
127
src/features/event/components/PromotionTemplatesSection.tsx
Normal file
127
src/features/event/components/PromotionTemplatesSection.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Props = {
|
||||
promotionOffsetDays: string;
|
||||
setPromotionOffsetDays: (v: string) => void;
|
||||
promotionStartDate: string;
|
||||
handlePromotionStartChange: (iso: string) => void;
|
||||
promotionStartRef: React.RefObject<HTMLInputElement | null>;
|
||||
formatDateForDisplay: (iso: string) => string;
|
||||
|
||||
mediaUrl: string;
|
||||
onMediaUrlChange: (url: string) => void;
|
||||
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onFileSelected: (file: File) => void;
|
||||
|
||||
mediaPreview: string | null;
|
||||
};
|
||||
|
||||
const PromotionTemplatesSection: React.FC<Props> = ({
|
||||
promotionOffsetDays,
|
||||
setPromotionOffsetDays,
|
||||
promotionStartDate,
|
||||
handlePromotionStartChange,
|
||||
promotionStartRef,
|
||||
formatDateForDisplay,
|
||||
mediaUrl,
|
||||
onMediaUrlChange,
|
||||
fileInputRef,
|
||||
onFileSelected,
|
||||
mediaPreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Promotion Templates</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Letzter Post vor Event</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-7 w-16 text-xs"
|
||||
value={promotionOffsetDays}
|
||||
onChange={(e) => setPromotionOffsetDays(e.target.value)}
|
||||
/>
|
||||
<span>Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-sm font-medium">Sektion 1 – Event-Promotion</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDateForDisplay(promotionStartDate) || 'Noch kein Datum gewählt'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (promotionStartRef.current) {
|
||||
if (promotionStartRef.current.showPicker) {
|
||||
promotionStartRef.current.showPicker();
|
||||
} else {
|
||||
promotionStartRef.current.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Datum wählen
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
ref={promotionStartRef}
|
||||
id="promotion_start"
|
||||
name="promotion_start"
|
||||
type="date"
|
||||
className="hidden"
|
||||
value={promotionStartDate}
|
||||
onChange={(e) => handlePromotionStartChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label>Bild / Video</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
|
||||
<Input placeholder="Media-URL (Bild oder Video)" value={mediaUrl} onChange={(e) => onMediaUrlChange(e.target.value)} />
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
onFileSelected(file);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
Datei auswählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mediaPreview && (
|
||||
<div className="mt-2">
|
||||
{mediaPreview.match(/\.mp4$|\.webm$|\.ogg$/i) ? (
|
||||
<video src={mediaPreview} className="h-32 rounded border object-cover" controls />
|
||||
) : (
|
||||
<img src={mediaPreview} alt="Event Media" className="h-32 rounded border object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Dieser Bereich wird später konkretisiert (Templates, Textbausteine, Scheduling etc.).
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromotionTemplatesSection;
|
||||
Loading…
x
Reference in New Issue
Block a user