fix(admin-v2): Add missing QRCodeUpload component and fix SDK imports
- Create admin-v2 compatible QRCodeUpload component (adapted from studio-v2) - Restore working DataPointsPreview and DocumentValidation from HEAD Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Database,
|
||||
FileText,
|
||||
List,
|
||||
Table,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
Clock,
|
||||
Users,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
@@ -56,79 +22,70 @@ const PLACEHOLDERS = [
|
||||
{
|
||||
placeholder: '[DATENPUNKTE_TABLE]',
|
||||
label: { de: 'Tabelle', en: 'Table' },
|
||||
description: { de: 'Fügt eine Markdown-Tabelle mit allen Datenpunkten ein', en: 'Inserts a markdown table with all data points' },
|
||||
icon: Table,
|
||||
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
|
||||
},
|
||||
{
|
||||
placeholder: '[DATENPUNKTE_LIST]',
|
||||
label: { de: 'Liste', en: 'List' },
|
||||
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
|
||||
icon: List,
|
||||
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
|
||||
},
|
||||
{
|
||||
placeholder: '[VERARBEITUNGSZWECKE]',
|
||||
label: { de: 'Zwecke', en: 'Purposes' },
|
||||
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
|
||||
icon: FileText,
|
||||
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
|
||||
},
|
||||
{
|
||||
placeholder: '[RECHTSGRUNDLAGEN]',
|
||||
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
|
||||
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
|
||||
icon: Shield,
|
||||
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
|
||||
},
|
||||
{
|
||||
placeholder: '[SPEICHERFRISTEN]',
|
||||
label: { de: 'Speicherfristen', en: 'Retention' },
|
||||
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
|
||||
icon: Clock,
|
||||
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
|
||||
},
|
||||
{
|
||||
placeholder: '[EMPFAENGER]',
|
||||
label: { de: 'Empfänger', en: 'Recipients' },
|
||||
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
|
||||
icon: Users,
|
||||
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
|
||||
},
|
||||
{
|
||||
placeholder: '[BESONDERE_KATEGORIEN]',
|
||||
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
|
||||
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
|
||||
icon: AlertTriangle,
|
||||
label: { de: 'Art. 9', en: 'Art. 9' },
|
||||
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
|
||||
},
|
||||
{
|
||||
placeholder: '[DRITTLAND_TRANSFERS]',
|
||||
label: { de: 'Drittländer', en: 'Third Countries' },
|
||||
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
|
||||
icon: Globe,
|
||||
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Risiko-Badge Varianten mapping
|
||||
* Risiko-Badge Farben
|
||||
*/
|
||||
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
function getRiskBadgeColor(riskLevel: RiskLevel): string {
|
||||
switch (riskLevel) {
|
||||
case 'HIGH':
|
||||
return 'destructive'
|
||||
return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'MEDIUM':
|
||||
return 'secondary'
|
||||
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
case 'LOW':
|
||||
default:
|
||||
return 'outline'
|
||||
return 'bg-green-100 text-green-700 border-green-200'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DataPointsPreview Komponente
|
||||
*
|
||||
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
|
||||
* Ermöglicht das schnelle Einfügen von Platzhaltern.
|
||||
*/
|
||||
export function DataPointsPreview({
|
||||
dataPoints,
|
||||
onInsertPlaceholder,
|
||||
language = 'de',
|
||||
}: DataPointsPreviewProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
|
||||
|
||||
// Gruppiere Datenpunkte nach Kategorie
|
||||
const byCategory = useMemo(() => {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
@@ -144,21 +101,15 @@ export function DataPointsPreview({
|
||||
const stats = useMemo(() => {
|
||||
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
|
||||
let specialCategoryCount = 0
|
||||
let explicitConsentCount = 0
|
||||
const recipients = new Set<string>()
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
riskCounts[dp.riskLevel]++
|
||||
if (dp.isSpecialCategory) specialCategoryCount++
|
||||
if (dp.requiresExplicitConsent) explicitConsentCount++
|
||||
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
|
||||
})
|
||||
|
||||
return {
|
||||
riskCounts,
|
||||
specialCategoryCount,
|
||||
explicitConsentCount,
|
||||
recipientCount: recipients.size,
|
||||
categoryCount: Object.keys(byCategory).length,
|
||||
}
|
||||
}, [dataPoints, byCategory])
|
||||
@@ -172,195 +123,173 @@ export function DataPointsPreview({
|
||||
})
|
||||
}, [byCategory])
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
)
|
||||
}
|
||||
|
||||
if (dataPoints.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{language === 'de'
|
||||
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus, um sie hier zu sehen.'
|
||||
: 'No data points selected. Select data points in the consent step to see them here.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{language === 'de'
|
||||
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
|
||||
: 'No data points selected. Select data points in the consent step.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
|
||||
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
|
||||
{/* Statistik-Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.riskCounts.HIGH > 0 && (
|
||||
<Badge variant="destructive">
|
||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.riskCounts.MEDIUM > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.riskCounts.LOW > 0 && (
|
||||
<Badge variant="outline">
|
||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.specialCategoryCount > 0 && (
|
||||
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
{stats.specialCategoryCount} Art. 9
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Statistik-Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{stats.riskCounts.HIGH > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
|
||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||
</span>
|
||||
)}
|
||||
{stats.riskCounts.MEDIUM > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||
</span>
|
||||
)}
|
||||
{stats.riskCounts.LOW > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
|
||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||
</span>
|
||||
)}
|
||||
{stats.specialCategoryCount > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{stats.specialCategoryCount} Art. 9
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="border-t border-gray-200 my-3"></div>
|
||||
|
||||
{/* Datenpunkte nach Kategorie */}
|
||||
<ScrollArea className="flex-1 -mr-4 pr-4">
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{sortedCategories.map(([category, points]) => {
|
||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||
if (!metadata) return null
|
||||
{/* Datenpunkte nach Kategorie */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
|
||||
{sortedCategories.map(([category, points]) => {
|
||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||
if (!metadata) return null
|
||||
const isExpanded = expandedCategories.includes(category)
|
||||
|
||||
return (
|
||||
<AccordionItem key={category} value={category}>
|
||||
<AccordionTrigger className="text-sm hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{metadata.code}
|
||||
</span>
|
||||
<span>
|
||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-auto mr-2">
|
||||
{points.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="space-y-1 pl-6">
|
||||
{points.map(dp => (
|
||||
<li
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between text-sm py-1"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dp.isSpecialCategory && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertTriangle className="h-3 w-3 text-orange-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{language === 'de'
|
||||
? 'Besondere Kategorie (Art. 9 DSGVO)'
|
||||
: 'Special Category (Art. 9 GDPR)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Badge
|
||||
variant={getRiskBadgeVariant(dp.riskLevel)}
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
return (
|
||||
<div key={category} className="border border-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
|
||||
{points.length}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Schnell-Einfügen Buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => (
|
||||
<Tooltip key={placeholder}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
{isExpanded && (
|
||||
<ul className="px-2 pb-2 space-y-1">
|
||||
{points.map(dp => (
|
||||
<li
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between text-sm py-1 pl-6"
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">
|
||||
{language === 'de' ? description.de : description.en}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{placeholder}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
|
||||
<Tooltip key={placeholder}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">
|
||||
{language === 'de' ? description.de : description.en}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{placeholder}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<span className="truncate max-w-[160px] text-gray-700">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dp.isSpecialCategory && (
|
||||
<svg className="w-3 h-3 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
|
||||
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 my-3"></div>
|
||||
|
||||
{/* Schnell-Einfügen Buttons */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">
|
||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
title={placeholder}
|
||||
>
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label }) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
title={placeholder}
|
||||
>
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
|
||||
import {
|
||||
validateDocument,
|
||||
@@ -30,28 +14,6 @@ interface DocumentValidationProps {
|
||||
onInsertPlaceholder?: (placeholder: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon für den Warnungstyp
|
||||
*/
|
||||
function getWarningIcon(type: ValidationWarning['type']) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'info':
|
||||
default:
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert-Variante für den Warnungstyp
|
||||
*/
|
||||
function getAlertVariant(type: ValidationWarning['type']): 'default' | 'destructive' {
|
||||
return type === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder-Vorschlag aus der Warnung extrahieren
|
||||
*/
|
||||
@@ -62,9 +24,6 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
|
||||
|
||||
/**
|
||||
* DocumentValidation Komponente
|
||||
*
|
||||
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
|
||||
* dem generierten Dokumentinhalt.
|
||||
*/
|
||||
export function DocumentValidation({
|
||||
dataPoints,
|
||||
@@ -72,6 +31,8 @@ export function DocumentValidation({
|
||||
language = 'de',
|
||||
onInsertPlaceholder,
|
||||
}: DocumentValidationProps) {
|
||||
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
|
||||
|
||||
// Führe Validierung durch
|
||||
const warnings = useMemo(() => {
|
||||
if (dataPoints.length === 0 || !documentContent) {
|
||||
@@ -85,21 +46,33 @@ export function DocumentValidation({
|
||||
const warningCount = warnings.filter(w => w.type === 'warning').length
|
||||
const infoCount = warnings.filter(w => w.type === 'info').length
|
||||
|
||||
const toggleWarning = (code: string) => {
|
||||
setExpandedWarnings(prev =>
|
||||
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
|
||||
)
|
||||
}
|
||||
|
||||
if (warnings.length === 0) {
|
||||
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
|
||||
if (dataPoints.length > 0 && documentContent.length > 100) {
|
||||
return (
|
||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
|
||||
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertTitle className="text-green-800 dark:text-green-200">
|
||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-green-700 dark:text-green-300">
|
||||
{language === 'de'
|
||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
||||
: 'All necessary sections for the selected data points are present.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-green-800">
|
||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||
</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
{language === 'de'
|
||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
||||
: 'All necessary sections for the selected data points are present.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -109,91 +82,126 @@ export function DocumentValidation({
|
||||
<div className="space-y-3">
|
||||
{/* Zusammenfassung */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-gray-700">
|
||||
{language === 'de' ? 'Validierung:' : 'Validation:'}
|
||||
</span>
|
||||
{errorCount > 0 && (
|
||||
<Badge variant="destructive">
|
||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
|
||||
{errorCount > 1 && (language === 'de' ? '' : 's')}
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
|
||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
|
||||
</span>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
|
||||
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||
</span>
|
||||
)}
|
||||
{infoCount > 0 && (
|
||||
<Badge variant="outline">
|
||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
|
||||
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||
</Badge>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
|
||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnungen */}
|
||||
{warnings.map((warning, index) => {
|
||||
const Icon = getWarningIcon(warning.type)
|
||||
const placeholder = extractPlaceholderSuggestion(warning)
|
||||
const isExpanded = expandedWarnings.includes(warning.code)
|
||||
const isError = warning.type === 'error'
|
||||
|
||||
return (
|
||||
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<AlertTitle className="flex items-center justify-between">
|
||||
<span>{warning.message}</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 space-y-2">
|
||||
{/* Vorschlag */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm">{warning.suggestion}</span>
|
||||
<div
|
||||
key={`${warning.code}-${index}`}
|
||||
className={`rounded-xl border p-4 ${
|
||||
isError
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className={`w-5 h-5 mt-0.5 ${isError ? 'text-red-600' : 'text-yellow-600'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isError ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Message */}
|
||||
<p className={`font-medium ${isError ? 'text-red-800' : 'text-yellow-800'}`}>
|
||||
{warning.message}
|
||||
</p>
|
||||
|
||||
{/* Suggestion */}
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">{warning.suggestion}</span>
|
||||
</div>
|
||||
|
||||
{/* Quick-Fix Button */}
|
||||
{placeholder && onInsertPlaceholder && (
|
||||
<button
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
|
||||
<code className="ml-1 text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{placeholder}
|
||||
</code>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Betroffene Datenpunkte */}
|
||||
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => toggleWarning(warning.code)}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{warning.affectedDataPoints.length}{' '}
|
||||
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="mt-2 text-xs space-y-0.5 pl-4">
|
||||
{warning.affectedDataPoints.slice(0, 5).map(dp => (
|
||||
<li key={dp.id} className="list-disc text-gray-600">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</li>
|
||||
))}
|
||||
{warning.affectedDataPoints.length > 5 && (
|
||||
<li className="list-none text-gray-400">
|
||||
... {language === 'de' ? 'und' : 'and'}{' '}
|
||||
{warning.affectedDataPoints.length - 5}{' '}
|
||||
{language === 'de' ? 'weitere' : 'more'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick-Fix Button */}
|
||||
{placeholder && onInsertPlaceholder && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
|
||||
<code className="ml-1 text-xs bg-muted px-1 rounded">
|
||||
{placeholder}
|
||||
</code>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Betroffene Datenpunkte */}
|
||||
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
{warning.affectedDataPoints.length}{' '}
|
||||
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<ul className="text-xs space-y-0.5 pl-4">
|
||||
{warning.affectedDataPoints.slice(0, 5).map(dp => (
|
||||
<li key={dp.id} className="list-disc">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</li>
|
||||
))}
|
||||
{warning.affectedDataPoints.length > 5 && (
|
||||
<li className="list-none text-muted-foreground">
|
||||
... {language === 'de' ? 'und' : 'and'}{' '}
|
||||
{warning.affectedDataPoints.length - 5}{' '}
|
||||
{language === 'de' ? 'weitere' : 'more'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
191
admin-v2/components/QRCodeUpload.tsx
Normal file
191
admin-v2/components/QRCodeUpload.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
interface QRCodeUploadProps {
|
||||
sessionId?: string
|
||||
onClose?: () => void
|
||||
onFileUploaded?: (file: UploadedFile) => void
|
||||
onFilesChanged?: (files: UploadedFile[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function QRCodeUpload({
|
||||
sessionId,
|
||||
onClose,
|
||||
onFileUploaded,
|
||||
onFilesChanged,
|
||||
className = ''
|
||||
}: QRCodeUploadProps) {
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
|
||||
const [uploadUrl, setUploadUrl] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const fetchUploads = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${sessionId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const newFiles = data.uploads || []
|
||||
if (newFiles.length > uploadedFiles.length) {
|
||||
const newlyAdded = newFiles.slice(uploadedFiles.length)
|
||||
newlyAdded.forEach((file: UploadedFile) => {
|
||||
if (onFileUploaded) onFileUploaded(file)
|
||||
})
|
||||
}
|
||||
setUploadedFiles(newFiles)
|
||||
if (onFilesChanged) onFilesChanged(newFiles)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch uploads:', error)
|
||||
}
|
||||
}, [sessionId, uploadedFiles.length, onFileUploaded, onFilesChanged])
|
||||
|
||||
useEffect(() => {
|
||||
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
const hostnameToIP: Record<string, string> = {
|
||||
'macmini': '192.168.178.100',
|
||||
'macmini.local': '192.168.178.100',
|
||||
}
|
||||
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
|
||||
if (baseUrl.includes(hostname)) baseUrl = baseUrl.replace(hostname, ip)
|
||||
})
|
||||
const uploadPath = `/upload/${sessionId || 'new'}`
|
||||
const fullUrl = `${baseUrl}${uploadPath}`
|
||||
setUploadUrl(fullUrl)
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
|
||||
setQrCodeUrl(qrApiUrl)
|
||||
setIsLoading(false)
|
||||
fetchUploads()
|
||||
setIsPolling(true)
|
||||
const pollInterval = setInterval(() => fetchUploads(), 3000)
|
||||
return () => { clearInterval(pollInterval); setIsPolling(false) }
|
||||
}, [sessionId])
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(uploadUrl)
|
||||
alert('Link kopiert!')
|
||||
} catch (err) {
|
||||
console.error('Kopieren fehlgeschlagen:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUpload = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?id=${id}`, { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
const newFiles = uploadedFiles.filter(f => f.id !== id)
|
||||
setUploadedFiles(newFiles)
|
||||
if (onFilesChanged) onFilesChanged(newFiles)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upload:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="rounded-3xl border bg-white border-slate-200 shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-purple-100">
|
||||
<span className="text-xl">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Mit Mobiltelefon hochladen</h3>
|
||||
<p className="text-sm text-slate-500">QR-Code scannen oder Link teilen</p>
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="p-2 rounded-lg transition-colors hover:bg-slate-100 text-slate-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="p-4 rounded-2xl bg-slate-50">
|
||||
{isLoading ? (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : qrCodeUrl ? (
|
||||
<img src={qrCodeUrl} alt="QR Code zum Hochladen" className="w-[200px] h-[200px]" />
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400">
|
||||
QR-Code nicht verfuegbar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-slate-600">
|
||||
Scannen Sie diesen Code mit Ihrem Handy,<br />um Dokumente direkt hochzuladen.
|
||||
</p>
|
||||
{isPolling && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Warte auf Uploads...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-6 p-4 rounded-xl bg-green-50 border border-green-200">
|
||||
<p className="text-sm font-medium text-green-700 mb-3">
|
||||
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
|
||||
</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-3 p-2 rounded-lg bg-white">
|
||||
<span className="text-lg">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate text-slate-900">{file.name}</p>
|
||||
<p className="text-xs text-slate-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<button onClick={() => deleteUpload(file.id)} className="p-1 rounded transition-colors hover:bg-red-100 text-red-500">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-xs mb-2 text-slate-400">Oder Link teilen:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={uploadUrl} readOnly className="flex-1 px-3 py-2 rounded-xl text-sm border bg-slate-50 border-slate-200 text-slate-700" />
|
||||
<button onClick={copyToClipboard} className="px-4 py-2 rounded-xl text-sm font-medium transition-colors bg-slate-200 text-slate-700 hover:bg-slate-300">
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user