first commit

This commit is contained in:
Tomáš Dvořák
2025-10-02 12:39:28 +02:00
commit 0fc92f8464
60 changed files with 11834 additions and 0 deletions
+547
View File
@@ -0,0 +1,547 @@
import './style.css'
import gsap from 'gsap'
// Configuration
const API_BASE_URL = '/api' // Always use /api - Vite proxy will handle routing in dev mode
const FACR_API_URL = 'https://facr.tdvorak.dev'
// ==================== Club Search ====================
const clubSearch = document.getElementById('clubSearch')
const searchResults = document.getElementById('searchResults')
const uploadSection = document.getElementById('uploadSection')
let searchTimeout
clubSearch.addEventListener('input', (e) => {
clearTimeout(searchTimeout)
const query = e.target.value.trim()
if (query.length < 2) {
searchResults.innerHTML = ''
return
}
searchTimeout = setTimeout(() => {
searchClubs(query)
}, 300)
})
async function searchClubs(query) {
searchResults.innerHTML = '<div class="text-center py-4"><div class="spinner mx-auto"></div></div>'
try {
const response = await fetch(`${API_BASE_URL}/clubs/search?q=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('Vyhledávání selhalo')
}
const clubs = await response.json()
await displaySearchResults(clubs)
} catch (error) {
console.error('Search error:', error)
searchResults.innerHTML = `
<div class="text-center py-4 text-red-400">
<p>Vyhledávání selhalo. Zkuste to prosím znovu.</p>
</div>
`
}
}
async function displaySearchResults(clubs) {
if (!clubs || clubs.length === 0) {
searchResults.innerHTML = `
<div class="text-center py-8 text-gray-400">
<p>Žádné kluby nenalezeny</p>
</div>
`
return
}
// Fetch logos from our API first
let existingLogos = []
try {
const logosResponse = await fetch(`${API_BASE_URL}/logos`)
if (logosResponse.ok) {
const data = await logosResponse.json()
existingLogos = data || []
}
} catch (error) {
console.log('Could not fetch existing logos:', error)
}
searchResults.innerHTML = clubs.map(club => {
// Check if we have this logo in our API
const existingLogo = existingLogos.find(l => l.id === club.id)
const logoUrl = existingLogo ? existingLogo.logo_url : (club.logo_url || '')
// Create logo HTML with fallback icon
let logoHtml = ''
if (logoUrl) {
logoHtml = `
<div class="flex-shrink-0 w-16 h-16 flex items-center justify-center bg-dark-border/30 rounded-lg p-2">
<img src="${logoUrl}"
alt="${club.name}"
class="max-w-full max-h-full object-contain"
onerror="this.parentElement.innerHTML='<svg class=\\'w-8 h-8 text-gray-500\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\\'></path></svg>'">
</div>
`
} else {
logoHtml = `
<div class="flex-shrink-0 w-16 h-16 flex items-center justify-center bg-dark-border/30 rounded-lg">
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
`
}
return `
<div class="club-result bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer" data-club='${JSON.stringify(club)}' data-logo-url='${logoUrl}'>
<div class="flex items-center gap-4">
${logoHtml}
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg truncate">${club.name}</h3>
<p class="text-sm text-gray-400">${club.type || 'football'}</p>
<p class="text-xs text-gray-500 font-mono mt-1 truncate">${club.id}</p>
${club.website ? `<p class="text-xs text-blue-400 mt-1 truncate">🌐 ${club.website}</p>` : ''}
${existingLogo ? '<p class="text-xs text-green-400 mt-1">✓ Logo již nahráno</p>' : ''}
</div>
<div class="flex flex-col gap-2 flex-shrink-0">
${existingLogo ? `<a href="/logo.html?id=${club.id}" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-smooth text-sm text-center" onclick="event.stopPropagation()">👁️ Detail</a>` : ''}
<button class="select-club px-4 py-2 bg-accent-blue rounded-lg hover:bg-blue-600 transition-smooth text-sm">
Vybrat
</button>
</div>
</div>
</div>
`
}).join('')
// Animate results
gsap.from('.club-result', {
duration: 0.4,
opacity: 0,
y: 20,
stagger: 0.08,
ease: 'power2.out'
})
// Add click handlers
document.querySelectorAll('.club-result').forEach(result => {
result.addEventListener('click', (e) => {
if (e.target.classList.contains('select-club') || e.target.closest('.select-club')) {
const clubData = JSON.parse(result.dataset.club)
selectClub(clubData)
}
})
})
}
function selectClub(club) {
// Fill form
document.getElementById('clubUuid').value = club.id
document.getElementById('clubName').value = club.name
document.getElementById('clubType').value = club.type || 'football'
document.getElementById('clubWebsite').value = club.website || ''
// Show upload section
uploadSection.classList.remove('hidden')
// Scroll to upload section
uploadSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Animate upload section
gsap.from(uploadSection, {
duration: 0.5,
opacity: 0,
y: 20,
ease: 'power2.out'
})
showNotification(`Vybráno: ${club.name}`, 'success')
}
// ==================== Website Search ====================
const searchWebsiteBtn = document.getElementById('searchWebsite')
const websiteSearchResults = document.getElementById('websiteSearchResults')
searchWebsiteBtn.addEventListener('click', async () => {
const clubName = document.getElementById('clubName').value.trim()
if (!clubName) {
showNotification('Nejprve zadejte název klubu', 'error')
return
}
searchWebsiteBtn.innerHTML = '<div class="spinner inline-block w-4 h-4"></div>'
searchWebsiteBtn.disabled = true
try {
const searchQuery = encodeURIComponent(`${clubName} český fotbal oficiální web`)
const searchUrl = `https://www.google.com/search?q=${searchQuery}`
websiteSearchResults.innerHTML = `
<div class="bg-dark-bg rounded-lg p-3 border border-dark-border">
<p class="text-sm text-gray-400 mb-2">Vyhledat web klubu:</p>
<a href="${searchUrl}" target="_blank" class="text-accent-blue hover:text-blue-400 text-sm">
🔍 Hledat "${clubName}" na Google
</a>
<p class="text-xs text-gray-500 mt-2">Zkopírujte URL oficiálního webu a vložte jej výše</p>
</div>
`
websiteSearchResults.classList.remove('hidden')
} catch (error) {
console.error('Website search error:', error)
} finally {
searchWebsiteBtn.innerHTML = '🔍 Hledat Online'
searchWebsiteBtn.disabled = false
}
})
// ==================== File Upload ====================
const uploadArea = document.getElementById('uploadArea')
const fileInput = document.getElementById('fileInput')
const filesPreviewArea = document.getElementById('filesPreviewArea')
const filesPreviewList = document.getElementById('filesPreviewList')
const uploadForm = document.getElementById('uploadForm')
let selectedFiles = []
// Click to browse
uploadArea.addEventListener('click', (e) => {
if (e.target === uploadArea || e.target.closest('#uploadArea')) {
fileInput.click()
}
})
// Drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault()
uploadArea.classList.add('dragover', 'border-accent-blue')
})
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover', 'border-accent-blue')
})
uploadArea.addEventListener('drop', (e) => {
e.preventDefault()
uploadArea.classList.remove('dragover', 'border-accent-blue')
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) {
handleFilesSelect(files)
}
})
// File input change
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFilesSelect(Array.from(e.target.files))
}
})
function handleFilesSelect(files) {
// Validate and filter files
const validFiles = []
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase()
if (ext === 'svg' || ext === 'png' || ext === 'pdf') {
validFiles.push({
file: file,
ext: ext,
name: '',
description: ''
})
}
}
if (validFiles.length === 0) {
showNotification('Vyberte prosím SVG, PNG nebo PDF soubory', 'error')
return
}
selectedFiles = validFiles
displayFilesPreview()
}
function displayFilesPreview() {
if (selectedFiles.length === 0) {
filesPreviewArea.classList.add('hidden')
return
}
filesPreviewArea.classList.remove('hidden')
filesPreviewList.innerHTML = selectedFiles.map((fileObj, index) => {
const sizeKB = (fileObj.file.size / 1024).toFixed(2)
const isPrimary = index === 0
return `
<div class="bg-dark-bg rounded-lg p-4 border border-dark-border" data-file-index="${index}">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-16 h-16 bg-dark-border/30 rounded flex items-center justify-center">
<span class="text-2xl">${fileObj.ext === 'svg' ? '📐' : fileObj.ext === 'pdf' ? '📄' : '🖼️'}</span>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h4 class="font-semibold">${fileObj.file.name}</h4>
${isPrimary ? '<span class="px-2 py-0.5 bg-accent-blue rounded text-xs">Hlavní</span>' : ''}
</div>
<p class="text-xs text-gray-400 mb-3">${fileObj.ext.toUpperCase()}${sizeKB} KB</p>
<div class="space-y-2">
<input
type="text"
placeholder="Název varianty (volitelné)"
value="${fileObj.name}"
onchange="updateFileMetadata(${index}, 'name', this.value)"
class="w-full bg-dark-card border border-dark-border rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-accent-blue transition-smooth"
>
<input
type="text"
placeholder="Popis (volitelné)"
value="${fileObj.description}"
onchange="updateFileMetadata(${index}, 'description', this.value)"
class="w-full bg-dark-card border border-dark-border rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-accent-blue transition-smooth"
>
</div>
</div>
<button type="button" onclick="removeFile(${index})" class="flex-shrink-0 p-2 text-red-400 hover:text-red-300 transition-smooth">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
`
}).join('')
gsap.from('.bg-dark-bg[data-file-index]', {
duration: 0.4,
opacity: 0,
y: 10,
stagger: 0.05,
ease: 'power2.out'
})
}
window.updateFileMetadata = function(index, field, value) {
if (selectedFiles[index]) {
selectedFiles[index][field] = value
}
}
window.removeFile = function(index) {
selectedFiles.splice(index, 1)
displayFilesPreview()
if (selectedFiles.length === 0) {
fileInput.value = ''
}
}
// Form submission
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault()
const uuid = document.getElementById('clubUuid').value.trim()
const clubName = document.getElementById('clubName').value.trim()
const clubType = document.getElementById('clubType').value
const clubWebsite = document.getElementById('clubWebsite').value.trim()
// Validation
if (!uuid) {
showNotification('Nejprve vyberte klub', 'error')
return
}
if (!clubName) {
showNotification('Název klubu je povinný', 'error')
return
}
if (selectedFiles.length === 0) {
showNotification('Vyberte prosím soubor loga', 'error')
return
}
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(uuid)) {
showNotification('Neplatný formát UUID', 'error')
return
}
await uploadLogos(uuid, clubName, clubType, clubWebsite, selectedFiles)
})
async function uploadLogos(uuid, clubName, clubType, clubWebsite, filesData) {
const submitBtn = document.getElementById('uploadSubmit')
const originalText = submitBtn.textContent
submitBtn.disabled = true
submitBtn.innerHTML = '<div class="spinner mx-auto"></div>'
try {
let uploadedCount = 0
// Upload each file
for (let i = 0; i < filesData.length; i++) {
const fileData = filesData[i]
const formData = new FormData()
formData.append('file', fileData.file)
formData.append('club_name', clubName)
if (clubType) formData.append('club_type', clubType)
if (clubWebsite) formData.append('club_website', clubWebsite)
// Add variant metadata if not the first file
if (i > 0) {
formData.append('variant', 'true')
if (fileData.name) formData.append('variant_name', fileData.name)
if (fileData.description) formData.append('variant_description', fileData.description)
} else {
// First file is primary
if (fileData.name) formData.append('variant_name', fileData.name || 'Hlavní')
if (fileData.description) formData.append('variant_description', fileData.description)
}
const response = await fetch(`${API_BASE_URL}/logos/${uuid}`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
uploadedCount++
submitBtn.innerHTML = `<div class="spinner mx-auto"></div> ${uploadedCount}/${filesData.length}`
}
showNotification(`${uploadedCount} ${uploadedCount === 1 ? 'logo' : 'loga'} úspěšně nahráno pro ${clubName}! ✓`, 'success')
// Reset form after delay
setTimeout(() => {
uploadForm.reset()
filesPreviewArea.classList.add('hidden')
selectedFiles = []
uploadSection.classList.add('hidden')
clubSearch.value = ''
searchResults.innerHTML = ''
fileInput.value = ''
}, 2000)
} catch (error) {
console.error('Upload error:', error)
showNotification(`Nahrání selhalo: ${error.message}`, 'error')
} finally {
submitBtn.disabled = false
submitBtn.textContent = originalText
}
}
// ==================== Utility Functions ====================
function showNotification(message, type = 'info') {
const notification = document.createElement('div')
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-accent-green' :
type === 'error' ? 'bg-red-500' :
'bg-accent-blue'
} text-white font-medium`
notification.textContent = message
document.body.appendChild(notification)
gsap.from(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.out'
})
setTimeout(() => {
gsap.to(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.in',
onComplete: () => notification.remove()
})
}, 3000)
}
// ==================== Initialize ====================
console.log('🇨🇿 České Kluby Loga API - Administrace')
console.log('Backend API:', API_BASE_URL)
console.log('FAČR API:', FACR_API_URL)
// Load from URL functionality
const loadFromUrlBtn = document.getElementById('loadFromUrl')
const logoUrlInput = document.getElementById('logoUrl')
loadFromUrlBtn.addEventListener('click', async () => {
const url = logoUrlInput.value.trim()
if (!url) {
showNotification('Zadejte prosím URL obrázku', 'error')
return
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
showNotification('URL musí začínat http:// nebo https://', 'error')
return
}
loadFromUrlBtn.disabled = true
loadFromUrlBtn.innerHTML = '<div class="spinner inline-block w-4 h-4"></div>'
try {
// Fetch the image from URL
const response = await fetch(url)
if (!response.ok) throw new Error('Nelze načíst obrázek')
const blob = await response.blob()
// Determine file extension from content type or URL
let ext = 'png'
const contentType = response.headers.get('content-type')
if (contentType) {
if (contentType.includes('svg')) ext = 'svg'
else if (contentType.includes('pdf')) ext = 'pdf'
else if (contentType.includes('png')) ext = 'png'
} else {
const urlExt = url.split('.').pop().toLowerCase().split('?')[0]
if (['svg', 'png', 'pdf'].includes(urlExt)) ext = urlExt
}
// Create a file from the blob
const filename = `logo-${Date.now()}.${ext}`
const file = new File([blob], filename, { type: blob.type })
handleFilesSelect([file])
showNotification('Obrázek úspěšně načten z URL', 'success')
} catch (error) {
console.error('Load from URL error:', error)
showNotification(`Chyba načítání: ${error.message}`, 'error')
} finally {
loadFromUrlBtn.disabled = false
loadFromUrlBtn.innerHTML = '📥 Načíst z URL'
}
})
// Show info notification
setTimeout(() => {
showNotification('Administrace: Vyhledejte kluby a nahrajte loga', 'info')
}, 1000)
+205
View File
@@ -0,0 +1,205 @@
import './style.css'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
// Configuration
const API_BASE_URL = '/api' // Always use /api - Vite proxy will handle routing in dev mode
// ==================== GSAP Animations ====================
// Hero animation on load
gsap.from('.hero-content', {
duration: 1,
opacity: 0,
y: 50,
ease: 'power3.out',
delay: 0.2
})
// Animate feature cards on scroll
gsap.utils.toArray('.feature-card').forEach((card, index) => {
gsap.from(card, {
scrollTrigger: {
trigger: card,
start: 'top 80%',
toggleActions: 'play none none reverse'
},
duration: 0.6,
opacity: 0,
y: 30,
delay: index * 0.1,
ease: 'power2.out'
})
})
// ==================== Logo Gallery ====================
const logoGrid = document.getElementById('logoGrid')
const loadingState = document.getElementById('loadingState')
const emptyState = document.getElementById('emptyState')
const gallerySearch = document.getElementById('gallerySearch')
const browseBtn = document.getElementById('browseBtn')
let allLogos = []
// Load logos
async function loadLogos() {
try {
const response = await fetch(`${API_BASE_URL}/logos`)
if (!response.ok) {
throw new Error('Failed to fetch logos')
}
allLogos = await response.json()
loadingState.classList.add('hidden')
if (allLogos.length === 0) {
emptyState.classList.remove('hidden')
} else {
displayLogos(allLogos)
}
} catch (error) {
console.error('Error loading logos:', error)
loadingState.classList.add('hidden')
emptyState.classList.remove('hidden')
}
}
// Display logos in grid
function displayLogos(logos) {
logoGrid.innerHTML = logos.map(logo => `
<div class="logo-card bg-dark-card rounded-xl p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer group" data-logo-id="${logo.id}">
<div class="aspect-square bg-dark-bg rounded-lg flex items-center justify-center mb-3 overflow-hidden">
<img
src="${logo.logo_url}"
alt="${logo.club_name}"
class="max-w-full max-h-full object-contain p-2 group-hover:scale-110 transition-transform duration-300"
loading="lazy"
onerror="this.parentElement.innerHTML='<svg class=\\'w-8 h-8 text-gray-500\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\\'></path></svg>'"
>
</div>
<h3 class="font-semibold text-sm truncate mb-1">${logo.club_name}</h3>
<p class="text-xs text-gray-400 truncate">${logo.club_type || 'fotbal'}</p>
<div class="flex gap-1 mt-2">
${logo.has_svg ? '<span class="px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">SVG</span>' : ''}
${logo.has_png ? '<span class="px-2 py-0.5 bg-green-500/20 text-green-400 rounded text-xs">PNG</span>' : ''}
</div>
</div>
`).join('')
// Animate logo cards
gsap.from('.logo-card', {
duration: 0.5,
opacity: 0,
scale: 0.9,
stagger: 0.05,
ease: 'power2.out'
})
// Add click handlers to navigate to logo detail page
document.querySelectorAll('.logo-card').forEach((card, index) => {
card.addEventListener('click', () => {
const logo = logos[index]
window.location.href = `/logo.html?id=${logo.id}`
})
})
}
// Filter logos
function filterLogos(query) {
const filtered = allLogos.filter(logo =>
logo.club_name.toLowerCase().includes(query.toLowerCase()) ||
(logo.club_city && logo.club_city.toLowerCase().includes(query.toLowerCase()))
)
displayLogos(filtered)
if (filtered.length === 0 && query) {
logoGrid.innerHTML = `
<div class="col-span-full text-center py-16">
<p class="text-xl text-gray-400">No logos found matching "${query}"</p>
</div>
`
}
}
// Copy logo URL
function copyLogoURL(url, clubName) {
navigator.clipboard.writeText(url).then(() => {
showNotification(`Logo URL copied for ${clubName}!`, 'success')
}).catch(() => {
showNotification('Failed to copy URL', 'error')
})
}
// ==================== Event Handlers ====================
// Gallery search
if (gallerySearch) {
let searchTimeout
gallerySearch.addEventListener('input', (e) => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
filterLogos(e.target.value.trim())
}, 300)
})
}
// Browse button - scroll to gallery
if (browseBtn) {
browseBtn.addEventListener('click', () => {
document.getElementById('logoGallery').scrollIntoView({
behavior: 'smooth',
block: 'start'
})
})
}
// ==================== Utility Functions ====================
function showNotification(message, type = 'info') {
const notification = document.createElement('div')
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-accent-green' :
type === 'error' ? 'bg-red-500' :
'bg-accent-blue'
} text-white font-medium`
notification.textContent = message
document.body.appendChild(notification)
gsap.from(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.out'
})
setTimeout(() => {
gsap.to(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.in',
onComplete: () => notification.remove()
})
}, 3000)
}
// ==================== Initialize ====================
console.log('🇨🇿 Czech Clubs Logos API - Home')
console.log('Backend API:', API_BASE_URL)
// Load logos on page load
loadLogos()
// Show welcome notification
setTimeout(() => {
showNotification('Welcome to Czech Clubs Logos API! 🇨🇿', 'info')
}, 1000)
+218
View File
@@ -0,0 +1,218 @@
import './style.css'
import gsap from 'gsap'
// Configuration
const API_BASE_URL = window.location.hostname === 'localhost' ? '/api' : 'http://localhost:8080'
// Get UUID from URL
const urlParams = new URLSearchParams(window.location.search)
const logoId = urlParams.get('id')
// DOM Elements
const loadingState = document.getElementById('loadingState')
const errorState = document.getElementById('errorState')
const logoDetail = document.getElementById('logoDetail')
// Initialize
if (!logoId) {
showError()
} else {
loadLogoDetails(logoId)
}
async function loadLogoDetails(id) {
try {
const response = await fetch(`${API_BASE_URL}/logos/${id}/json`)
if (!response.ok) {
throw new Error('Logo not found')
}
const logo = await response.json()
displayLogoDetails(logo)
} catch (error) {
console.error('Error loading logo:', error)
showError()
}
}
function displayLogoDetails(logo) {
// Hide loading, show content
loadingState.classList.add('hidden')
logoDetail.classList.remove('hidden')
// Club Info
document.getElementById('clubName').textContent = logo.club_name
document.getElementById('clubMeta').textContent = `${logo.club_type || 'fotbal'}`
// Logo Previews
const previewUrl = logo.logo_url || logo.logo_url_png || logo.logo_url_svg
document.getElementById('logoPreviewLight').src = previewUrl
document.getElementById('logoPreviewDark').src = previewUrl
// Formats
const formatsGrid = document.getElementById('formatsGrid')
const formats = []
if (logo.has_png && logo.logo_url_png) {
formats.push({
name: 'PNG',
url: logo.logo_url_png,
size: formatFileSize(logo.file_size_png),
icon: '🖼️',
color: 'bg-blue-600'
})
}
if (logo.has_svg && logo.logo_url_svg) {
formats.push({
name: 'SVG',
url: logo.logo_url_svg,
size: formatFileSize(logo.file_size_svg),
icon: '📐',
color: 'bg-green-600'
})
}
formatsGrid.innerHTML = formats.map(format => `
<a href="${format.url}" download class="block bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth">
<div class="flex items-center justify-between mb-3">
<span class="text-2xl">${format.icon}</span>
<span class="px-2 py-1 ${format.color} rounded text-xs font-semibold">${format.name}</span>
</div>
<h3 class="font-semibold mb-1">${format.name} Format</h3>
<p class="text-sm text-gray-400">${format.size}</p>
<div class="mt-3 flex items-center text-accent-blue text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Stáhnout
</div>
</a>
`).join('')
// Variants (if supported)
if (logo.variants && logo.variants.length > 0) {
document.getElementById('variantsSection').classList.remove('hidden')
const variantsGrid = document.getElementById('variantsGrid')
variantsGrid.innerHTML = logo.variants.map(variant => `
<div class="bg-dark-bg rounded-lg p-4 border border-dark-border">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-20 h-20 bg-white rounded flex items-center justify-center p-2">
<img src="${variant.url}" alt="${variant.name}" class="max-w-full max-h-full object-contain">
</div>
<div class="flex-1">
<h3 class="font-semibold mb-1">${variant.name || 'Varianta'}</h3>
${variant.description ? `<p class="text-sm text-gray-400 mb-2">${variant.description}</p>` : ''}
<div class="flex items-center gap-3 text-xs text-gray-500">
<span>${variant.format.toUpperCase()}</span>
<span>•</span>
<span>${formatFileSize(variant.size)}</span>
</div>
</div>
<a href="${variant.url}" download class="px-3 py-2 bg-accent-blue rounded-lg hover:bg-blue-600 transition-smooth text-sm">
⬇️
</a>
</div>
</div>
`).join('')
} else {
document.getElementById('variantsSection').classList.add('hidden')
}
// Metadata
document.getElementById('logoUuid').textContent = logo.id
document.getElementById('clubType').textContent = logo.club_type || 'fotbal'
const website = logo.club_website || 'N/A'
const websiteElement = document.getElementById('clubWebsite')
if (logo.club_website) {
websiteElement.innerHTML = `<a href="${logo.club_website}" target="_blank" class="text-accent-blue hover:underline">${logo.club_website}</a>`
} else {
websiteElement.textContent = website
}
document.getElementById('uploadDate').textContent = formatDate(logo.created_at)
// API URLs
const baseUrl = window.location.origin
document.getElementById('apiUrlDefault').textContent = `${baseUrl}/logos/${logo.id}`
document.getElementById('apiUrlJson').textContent = `${baseUrl}/logos/${logo.id}/json`
// Animate
gsap.from('#logoDetail > *', {
duration: 0.6,
opacity: 0,
y: 20,
stagger: 0.1,
ease: 'power2.out'
})
}
function showError() {
loadingState.classList.add('hidden')
errorState.classList.remove('hidden')
}
function formatFileSize(bytes) {
if (!bytes) return 'N/A'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleDateString('cs-CZ', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
window.copyToClipboard = function(elementId) {
const element = document.getElementById(elementId)
const text = element.textContent
navigator.clipboard.writeText(text).then(() => {
showNotification('URL zkopírováno do schránky', 'success')
}).catch(err => {
console.error('Failed to copy:', err)
showNotification('Chyba při kopírování', 'error')
})
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div')
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-accent-green' :
type === 'error' ? 'bg-red-500' :
'bg-accent-blue'
} text-white font-medium`
notification.textContent = message
document.body.appendChild(notification)
gsap.from(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.out'
})
setTimeout(() => {
gsap.to(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.in',
onComplete: () => notification.remove()
})
}, 3000)
}
console.log('🇨🇿 České Kluby Loga API - Detail Loga')
console.log('Logo ID:', logoId)
+424
View File
@@ -0,0 +1,424 @@
import './style.css'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
// Configuration
const API_BASE_URL = '/api' // Always use /api - Vite proxy will handle routing in dev mode
const FACR_API_URL = 'https://facr.tdvorak.dev'
// ==================== GSAP Animations ====================
// Hero animation on load
gsap.from('.hero-content', {
duration: 1,
opacity: 0,
y: 50,
ease: 'power3.out',
delay: 0.2
})
// Animate feature cards on scroll
gsap.utils.toArray('.feature-card').forEach((card, index) => {
gsap.from(card, {
scrollTrigger: {
trigger: card,
start: 'top 80%',
toggleActions: 'play none none reverse'
},
duration: 0.6,
opacity: 0,
y: 30,
delay: index * 0.1,
ease: 'power2.out'
})
})
// Animate API endpoint cards
gsap.utils.toArray('.api-section .card-hover').forEach((card, index) => {
gsap.from(card, {
scrollTrigger: {
trigger: card,
start: 'top 85%',
toggleActions: 'play none none reverse'
},
duration: 0.5,
opacity: 0,
x: -20,
delay: index * 0.08,
ease: 'power2.out'
})
})
// ==================== UI State Management ====================
const searchSection = document.getElementById('searchSection')
const uploadSection = document.getElementById('uploadSection')
const searchBtn = document.getElementById('searchBtn')
const uploadBtn = document.getElementById('uploadBtn')
// Section toggle handlers
searchBtn.addEventListener('click', () => {
gsap.to(searchSection, {
duration: 0.5,
opacity: 1,
display: 'block',
ease: 'power2.inOut'
})
gsap.to(uploadSection, {
duration: 0.5,
opacity: 0,
display: 'none',
ease: 'power2.inOut'
})
// Smooth scroll to section
searchSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
uploadBtn.addEventListener('click', () => {
gsap.to(uploadSection, {
duration: 0.5,
opacity: 1,
display: 'block',
ease: 'power2.inOut'
})
gsap.to(searchSection, {
duration: 0.5,
opacity: 0,
display: 'none',
ease: 'power2.inOut'
})
// Smooth scroll to section
uploadSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
// ==================== Search Functionality ====================
const searchInput = document.getElementById('searchInput')
const searchResults = document.getElementById('searchResults')
let searchTimeout
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout)
const query = e.target.value.trim()
if (query.length < 2) {
searchResults.innerHTML = ''
return
}
// Debounce search
searchTimeout = setTimeout(() => {
searchClubs(query)
}, 300)
})
async function searchClubs(query) {
searchResults.innerHTML = '<div class="text-center py-4"><div class="spinner mx-auto"></div></div>'
try {
// Try to fetch from your backend API first
// If backend is not ready, show demo data
const response = await fetch(`${API_BASE_URL}/clubs/search?q=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('Backend not available')
}
const data = await response.json()
displaySearchResults(data)
} catch (error) {
console.log('Backend not available, showing demo data')
// Demo data when backend is not ready
displaySearchResults(getDemoClubs(query))
}
}
function getDemoClubs(query) {
const demoClubs = [
{
id: '11111111-2222-3333-4444-555555555555',
name: 'SK Slavia Praha',
city: 'Praha',
type: 'football'
},
{
id: '22222222-3333-4444-5555-666666666666',
name: 'AC Sparta Praha',
city: 'Praha',
type: 'football'
},
{
id: '33333333-4444-5555-6666-777777777777',
name: 'FC Viktoria Plzeň',
city: 'Plzeň',
type: 'football'
},
{
id: '44444444-5555-6666-7777-888888888888',
name: 'FC Baník Ostrava',
city: 'Ostrava',
type: 'football'
}
]
return demoClubs.filter(club =>
club.name.toLowerCase().includes(query.toLowerCase())
)
}
function displaySearchResults(clubs) {
if (clubs.length === 0) {
searchResults.innerHTML = `
<div class="text-center py-8 text-gray-400">
<p>No clubs found</p>
</div>
`
return
}
searchResults.innerHTML = clubs.map(club => `
<div class="club-result bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="font-semibold text-lg">${club.name}</h3>
<p class="text-sm text-gray-400">${club.city || 'N/A'}${club.type || 'football'}</p>
<p class="text-xs text-gray-500 font-mono mt-1">${club.id}</p>
</div>
<button
class="copy-uuid px-4 py-2 bg-accent-blue/20 text-accent-blue rounded-lg hover:bg-accent-blue/30 transition-smooth text-sm"
data-uuid="${club.id}"
>
Copy UUID
</button>
</div>
</div>
`).join('')
// Animate results
gsap.from('.club-result', {
duration: 0.4,
opacity: 0,
y: 20,
stagger: 0.08,
ease: 'power2.out'
})
// Add copy UUID handlers
document.querySelectorAll('.copy-uuid').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
const uuid = btn.dataset.uuid
copyToClipboard(uuid)
// Visual feedback
const originalText = btn.textContent
btn.textContent = '✓ Copied!'
setTimeout(() => {
btn.textContent = originalText
}, 2000)
})
})
// Add click handlers to fill upload form
document.querySelectorAll('.club-result').forEach(result => {
result.addEventListener('click', () => {
const uuid = result.querySelector('.copy-uuid').dataset.uuid
document.getElementById('clubUuid').value = uuid
uploadBtn.click() // Switch to upload section
// Highlight the UUID input
const uuidInput = document.getElementById('clubUuid')
gsap.fromTo(uuidInput,
{ backgroundColor: 'rgba(59, 130, 246, 0.2)' },
{ backgroundColor: 'transparent', duration: 1, ease: 'power2.out' }
)
})
})
}
// ==================== Upload Functionality ====================
const uploadArea = document.getElementById('uploadArea')
const fileInput = document.getElementById('fileInput')
const previewArea = document.getElementById('previewArea')
const previewImage = document.getElementById('previewImage')
const uploadSubmit = document.getElementById('uploadSubmit')
const clubUuidInput = document.getElementById('clubUuid')
let selectedFile = null
// Click to browse
uploadArea.addEventListener('click', () => {
fileInput.click()
})
// Drag and drop handlers
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault()
uploadArea.classList.add('dragover')
})
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover')
})
uploadArea.addEventListener('drop', (e) => {
e.preventDefault()
uploadArea.classList.remove('dragover')
const files = e.dataTransfer.files
if (files.length > 0) {
handleFileSelect(files[0])
}
})
// File input change
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0])
}
})
function handleFileSelect(file) {
// Validate file type
if (!file.type.match('image/(svg\\+xml|png)')) {
showNotification('Please select an SVG or PNG file', 'error')
return
}
selectedFile = file
// Show preview
const reader = new FileReader()
reader.onload = (e) => {
previewImage.src = e.target.result
// Animate preview
gsap.to(previewArea, {
duration: 0.5,
opacity: 1,
display: 'block',
ease: 'power2.out'
})
}
reader.readAsDataURL(file)
}
// Upload submit
uploadSubmit.addEventListener('click', async () => {
const uuid = clubUuidInput.value.trim()
if (!uuid) {
showNotification('Please enter a club UUID', 'error')
return
}
if (!selectedFile) {
showNotification('Please select a logo file', 'error')
return
}
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(uuid)) {
showNotification('Invalid UUID format', 'error')
return
}
await uploadLogo(uuid, selectedFile)
})
async function uploadLogo(uuid, file) {
const formData = new FormData()
formData.append('file', file)
// Disable button and show loading
uploadSubmit.disabled = true
uploadSubmit.innerHTML = '<div class="spinner mx-auto"></div>'
try {
const response = await fetch(`${API_BASE_URL}/logos/${uuid}`, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('Upload failed')
}
showNotification('Logo uploaded successfully! ✓', 'success')
// Reset form
setTimeout(() => {
clubUuidInput.value = ''
fileInput.value = ''
selectedFile = null
previewArea.style.display = 'none'
}, 1500)
} catch (error) {
console.error('Upload error:', error)
showNotification('Upload failed. Make sure the backend is running.', 'error')
} finally {
uploadSubmit.disabled = false
uploadSubmit.textContent = 'Upload Logo'
}
}
// ==================== Utility Functions ====================
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showNotification('UUID copied to clipboard!', 'success')
}).catch(() => {
showNotification('Failed to copy UUID', 'error')
})
}
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div')
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-accent-green' :
type === 'error' ? 'bg-red-500' :
'bg-accent-blue'
} text-white font-medium`
notification.textContent = message
document.body.appendChild(notification)
// Animate in
gsap.from(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.out'
})
// Remove after 3 seconds
setTimeout(() => {
gsap.to(notification, {
duration: 0.3,
opacity: 0,
y: -20,
ease: 'power2.in',
onComplete: () => notification.remove()
})
}, 3000)
}
// ==================== Initialize ====================
console.log('🇨🇿 Czech Clubs Logos API Frontend')
console.log('Backend API:', API_BASE_URL)
console.log('FAČR API:', FACR_API_URL)
// Show a welcome notification
setTimeout(() => {
showNotification('Welcome to Czech Clubs Logos API! 🇨🇿', 'info')
}, 1000)
+99
View File
@@ -0,0 +1,99 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar for dark mode */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #0a0e1a;
}
::-webkit-scrollbar-thumb {
background: #1f2937;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #374151;
}
/* Smooth transitions */
.transition-smooth {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Card hover effects */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #3b82f6 0%, #10b981 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Upload area styling */
.upload-area {
border: 2px dashed #374151;
transition: all 0.3s ease;
}
.upload-area.dragover {
border-color: #3b82f6;
background-color: rgba(59, 130, 246, 0.05);
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
/* Loading spinner */
.spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: #3b82f6;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}