This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/studio-v2/components/QRCodeUpload.tsx
BreakPilot Dev 19855efacc
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
feat: BreakPilot PWA - Full codebase (clean push without large binaries)
All services: admin-v2, studio-v2, website, ai-compliance-sdk,
consent-service, klausur-service, voice-service, and infrastructure.
Large PDFs and compiled binaries excluded via .gitignore.
2026-02-11 13:25:58 +01:00

335 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
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 { isDark } = useTheme()
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)
// Format file size
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]
}
// Fetch uploads for this session
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 || []
// Check if there are new files
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])
// Initialize QR code and start polling
useEffect(() => {
// Generate Upload-URL
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
// Hostname to IP mapping for local network
const hostnameToIP: Record<string, string> = {
'macmini': '192.168.178.100',
'macmini.local': '192.168.178.100',
}
// Replace known hostnames with IP addresses
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)
// Generate QR code via external API
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
setQrCodeUrl(qrApiUrl)
setIsLoading(false)
// Initial fetch
fetchUploads()
// Start polling for new uploads every 3 seconds
setIsPolling(true)
const pollInterval = setInterval(() => {
fetchUploads()
}, 3000)
return () => {
clearInterval(pollInterval)
setIsPolling(false)
}
}, [sessionId]) // Note: fetchUploads is intentionally not in deps to avoid re-creating interval
// Separate effect for fetching when uploadedFiles changes
useEffect(() => {
// This is just for the callback effect, actual polling is in the other useEffect
}, [uploadedFiles, onFilesChanged])
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 p-6 ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white border-slate-200 shadow-lg'
}`}>
{/* Header */}
<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 ${
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
}`}>
<span className="text-xl">📱</span>
</div>
<div>
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Mit Mobiltelefon hochladen
</h3>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
QR-Code scannen oder Link teilen
</p>
</div>
</div>
{onClose && (
<button
onClick={onClose}
className={`p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-white/10 text-white/60' : '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>
{/* QR Code */}
<div className="flex flex-col items-center">
<div className={`p-4 rounded-2xl ${isDark ? 'bg-white' : '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 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Scannen Sie diesen Code mit Ihrem Handy,<br />
um Dokumente direkt hochzuladen.
</p>
{/* Polling indicator */}
{isPolling && (
<div className={`mt-2 flex items-center gap-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Warte auf Uploads...
</div>
)}
</div>
{/* Uploaded Files List */}
{uploadedFiles.length > 0 && (
<div className={`mt-6 p-4 rounded-xl ${
isDark ? 'bg-green-500/10 border border-green-500/20' : 'bg-green-50 border border-green-200'
}`}>
<div className="flex items-center justify-between mb-3">
<p className={`text-sm font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
</p>
</div>
<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 ${
isDark ? 'bg-white/5' : '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 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{file.name}
</p>
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{formatFileSize(file.size)}
</p>
</div>
<button
onClick={() => deleteUpload(file.id)}
className={`p-1 rounded transition-colors ${
isDark ? 'hover:bg-red-500/20 text-red-400' : '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>
)}
{/* Link teilen */}
<div className="mt-6">
<p className={`text-xs mb-2 ${isDark ? 'text-white/40' : '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 ${
isDark
? 'bg-white/5 border-white/10 text-white/80'
: '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 ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
Kopieren
</button>
</div>
</div>
{/* Network hint - only show if no files uploaded yet */}
{uploadedFiles.length === 0 && (
<div className={`mt-6 p-4 rounded-xl ${
isDark ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-amber-50 border border-amber-200'
}`}>
<div className="flex items-start gap-3">
<span className="text-lg"></span>
<div>
<p className={`text-sm font-medium ${isDark ? 'text-amber-300' : 'text-amber-900'}`}>
Nur im lokalen Netzwerk
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-amber-300/70' : 'text-amber-700'}`}>
Ihr Mobiltelefon muss mit dem gleichen Netzwerk verbunden sein.
</p>
</div>
</div>
</div>
)}
{/* Tip */}
<div className={`mt-4 p-4 rounded-xl ${
isDark ? 'bg-blue-500/10 border border-blue-500/20' : 'bg-blue-50 border border-blue-200'
}`}>
<div className="flex items-start gap-3">
<span className="text-lg">💡</span>
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-300' : 'text-blue-900'}`}>
Tipp: Mehrere Seiten scannen
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-blue-300/70' : 'text-blue-700'}`}>
Sie koennen beliebig viele Fotos hochladen.
</p>
</div>
</div>
</div>
</div>
</div>
)
}
// Export the UploadedFile type for use in other components
export type { UploadedFile }