mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #63
This commit is contained in:
@@ -0,0 +1,290 @@
|
|||||||
|
# File Preview Component
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A comprehensive file preview component that provides inline viewing and downloading capabilities for various file types including images, PDFs, videos, audio files, and Office documents (PPTX, DOCX, XLSX).
|
||||||
|
|
||||||
|
## Component Location
|
||||||
|
`frontend/src/components/common/FilePreview.tsx`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. **Supported File Types**
|
||||||
|
|
||||||
|
#### Full Preview Support (in modal)
|
||||||
|
- **Images**: JPG, JPEG, PNG, GIF, SVG, WebP, BMP
|
||||||
|
- Direct inline display
|
||||||
|
- Click to zoom in modal
|
||||||
|
- Error handling for failed loads
|
||||||
|
|
||||||
|
- **PDFs**:
|
||||||
|
- Embedded iframe viewer
|
||||||
|
- Native browser PDF viewer
|
||||||
|
- Maintains aspect ratio
|
||||||
|
|
||||||
|
- **Videos**: MP4, AVI, MOV, WebM
|
||||||
|
- HTML5 video player with controls
|
||||||
|
- 16:9 aspect ratio
|
||||||
|
- Support for multiple formats
|
||||||
|
|
||||||
|
- **Audio**: MP3, WAV, OGG
|
||||||
|
- HTML5 audio player with controls
|
||||||
|
- Icon display with player below
|
||||||
|
|
||||||
|
#### Online Preview Support
|
||||||
|
- **PowerPoint**: PPTX, PPT
|
||||||
|
- "Zobrazit online" button using Microsoft Office Online Viewer
|
||||||
|
- Download option
|
||||||
|
- File info display
|
||||||
|
|
||||||
|
- **Word**: DOCX, DOC
|
||||||
|
- Same online viewer support
|
||||||
|
- Icon and file metadata
|
||||||
|
|
||||||
|
- **Excel**: XLSX, XLS
|
||||||
|
- Online spreadsheet viewer
|
||||||
|
- Download and preview options
|
||||||
|
|
||||||
|
### 2. **Display Modes**
|
||||||
|
|
||||||
|
#### Compact Mode (default)
|
||||||
|
```tsx
|
||||||
|
<FilePreview
|
||||||
|
url="/uploads/document.pdf"
|
||||||
|
name="Document.pdf"
|
||||||
|
mimeType="application/pdf"
|
||||||
|
size={1024000}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
- Shows file icon, name, and size
|
||||||
|
- Preview and Download buttons
|
||||||
|
- Responsive layout
|
||||||
|
|
||||||
|
#### Inline Mode (for images)
|
||||||
|
```tsx
|
||||||
|
<FilePreview
|
||||||
|
url="/uploads/photo.jpg"
|
||||||
|
name="Photo.jpg"
|
||||||
|
mimeType="image/jpeg"
|
||||||
|
size={512000}
|
||||||
|
showInline={true}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
- Displays image directly in the page
|
||||||
|
- Click to open full-size modal
|
||||||
|
- Max height: 400px
|
||||||
|
|
||||||
|
### 3. **User Interface Elements**
|
||||||
|
|
||||||
|
#### File Information
|
||||||
|
- Icon based on file type (color-coded)
|
||||||
|
- File name (truncated if too long)
|
||||||
|
- File size (displayed in KB or MB)
|
||||||
|
- MIME type indicator
|
||||||
|
|
||||||
|
#### Action Buttons
|
||||||
|
- **Náhled** (Preview): Opens modal with file preview
|
||||||
|
- **Stáhnout** (Download): Direct download link
|
||||||
|
- **Zobrazit online** (View Online): For Office documents
|
||||||
|
|
||||||
|
#### Modal Preview
|
||||||
|
- Full-screen modal (90vw × 90vh)
|
||||||
|
- Dark overlay background
|
||||||
|
- Close button
|
||||||
|
- Download button in footer
|
||||||
|
- Responsive content area
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FilePreviewProps {
|
||||||
|
url: string; // File URL (relative or absolute)
|
||||||
|
name?: string; // Display name (defaults to filename from URL)
|
||||||
|
mimeType?: string; // MIME type (e.g., 'image/jpeg', 'application/pdf')
|
||||||
|
size?: number; // File size in bytes
|
||||||
|
showInline?: boolean; // Show inline preview for images (default: false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Usage (Event Attachments)
|
||||||
|
```tsx
|
||||||
|
import FilePreview from '../components/common/FilePreview';
|
||||||
|
|
||||||
|
// In your component
|
||||||
|
{attachments.map((att, idx) => (
|
||||||
|
<FilePreview
|
||||||
|
key={idx}
|
||||||
|
url={att.url}
|
||||||
|
name={att.name}
|
||||||
|
mimeType={att.mime_type}
|
||||||
|
size={att.size}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Image Gallery
|
||||||
|
```tsx
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<FilePreview
|
||||||
|
key={idx}
|
||||||
|
url={img.url}
|
||||||
|
name={img.name}
|
||||||
|
mimeType="image/jpeg"
|
||||||
|
size={img.size}
|
||||||
|
showInline={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDF Document
|
||||||
|
```tsx
|
||||||
|
<FilePreview
|
||||||
|
url="/uploads/report.pdf"
|
||||||
|
name="Monthly Report.pdf"
|
||||||
|
mimeType="application/pdf"
|
||||||
|
size={2048000}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PowerPoint Presentation
|
||||||
|
```tsx
|
||||||
|
<FilePreview
|
||||||
|
url="/uploads/presentation.pptx"
|
||||||
|
name="Company Presentation.pptx"
|
||||||
|
mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
size={3145728}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Type Detection
|
||||||
|
|
||||||
|
The component automatically detects file types based on MIME type:
|
||||||
|
|
||||||
|
| MIME Type Pattern | Icon | Color | Preview Available |
|
||||||
|
|------------------|------|-------|-------------------|
|
||||||
|
| `image/*` | FiImage | Purple | ✅ Yes (inline) |
|
||||||
|
| `application/pdf` | FiFileText | Red | ✅ Yes (iframe) |
|
||||||
|
| `video/*` | FiVideo | Pink | ✅ Yes (player) |
|
||||||
|
| `audio/*` | FiMusic | Green | ✅ Yes (player) |
|
||||||
|
| `*word*`, `*document*` | FiFileText | Blue | ⚠️ Online only |
|
||||||
|
| `*sheet*`, `*excel*` | FiFile | Green | ⚠️ Online only |
|
||||||
|
| `*presentation*`, `*powerpoint*` | FiFile | Orange | ⚠️ Online only |
|
||||||
|
| Other | FiFile | Gray | ❌ Download only |
|
||||||
|
|
||||||
|
## Microsoft Office Online Viewer
|
||||||
|
|
||||||
|
For Office documents (PPTX, DOCX, XLSX), the component offers a "Zobrazit online" button that opens the file in Microsoft Office Online Viewer:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://view.officeapps.live.com/op/view.aspx?src=[ENCODED_FILE_URL]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- File must be publicly accessible
|
||||||
|
- Supported formats: DOCX, XLSX, PPTX
|
||||||
|
- Internet connection required
|
||||||
|
|
||||||
|
## Integration with ActivityDetailPage
|
||||||
|
|
||||||
|
The FilePreview component has been integrated into `ActivityDetailPage.tsx`:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- Simple download button
|
||||||
|
- No preview functionality
|
||||||
|
- Minimal file information
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Full preview support for all file types
|
||||||
|
- Inline image display
|
||||||
|
- Modal viewer for PDFs, videos, audio
|
||||||
|
- Office document online viewer
|
||||||
|
- Better file metadata display
|
||||||
|
|
||||||
|
## Styling & Theming
|
||||||
|
|
||||||
|
The component uses Chakra UI's color mode values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
|
const mutedText = useColorModeValue('gray.600', 'gray.300');
|
||||||
|
const linkColor = useColorModeValue('blue.600', 'blue.300');
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures proper display in both light and dark modes.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Image Loading Errors
|
||||||
|
- Catches `onError` event
|
||||||
|
- Displays fallback message
|
||||||
|
- Provides download option
|
||||||
|
|
||||||
|
### Video/Audio Playback Errors
|
||||||
|
- Shows browser's native error message
|
||||||
|
- Falls back to download option
|
||||||
|
|
||||||
|
### PDF Loading Issues
|
||||||
|
- Browser's native PDF viewer handles errors
|
||||||
|
- Users can download if preview fails
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Proper ARIA labels
|
||||||
|
- Keyboard navigation support (modal)
|
||||||
|
- Screen reader friendly
|
||||||
|
- High contrast icons
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
| Browser | Images | PDFs | Video | Audio | Office Docs |
|
||||||
|
|---------|--------|------|-------|-------|-------------|
|
||||||
|
| Chrome 90+ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Firefox 88+ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Safari 14+ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Edge 90+ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
**Note:** Office document online viewing requires an internet connection and works on all modern browsers.
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Lazy Loading**: Images are loaded only when visible
|
||||||
|
2. **Modal Loading**: Preview content loads only when modal opens
|
||||||
|
3. **External Links**: Downloads don't trigger component re-renders
|
||||||
|
4. **Error Boundaries**: Failed loads don't crash the component
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
|
||||||
|
1. **Caching**: Cache preview content
|
||||||
|
2. **Thumbnails**: Generate thumbnails for large files
|
||||||
|
3. **Full-screen mode**: Add full-screen toggle for videos
|
||||||
|
4. **Download progress**: Show progress bar for large files
|
||||||
|
5. **Zoom controls**: Add zoom in/out for images and PDFs
|
||||||
|
6. **Print option**: Add print button for documents
|
||||||
|
7. **Share functionality**: Social sharing buttons
|
||||||
|
8. **Multi-page PDF navigation**: Add page controls for PDFs
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@chakra-ui/react`: UI components
|
||||||
|
- `react-icons/fi`: Feather icons
|
||||||
|
- `assetUrl` utility: URL resolution helper
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Image preview opens in modal
|
||||||
|
- [ ] PDF displays in iframe
|
||||||
|
- [ ] Video plays with controls
|
||||||
|
- [ ] Audio plays correctly
|
||||||
|
- [ ] Office docs show "View Online" button
|
||||||
|
- [ ] Download button works
|
||||||
|
- [ ] Error states display properly
|
||||||
|
- [ ] Responsive on mobile
|
||||||
|
- [ ] Dark mode looks correct
|
||||||
|
- [ ] File sizes format correctly (KB/MB)
|
||||||
|
- [ ] Modal closes properly
|
||||||
|
- [ ] External links open in new tab
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# File Preview - Quick Guide
|
||||||
|
|
||||||
|
## What's New? 🎉
|
||||||
|
|
||||||
|
Your file attachments now have **inline preview functionality**! Users can view files directly on the page without downloading them.
|
||||||
|
|
||||||
|
## Supported File Types
|
||||||
|
|
||||||
|
### ✅ **Full Preview (in modal)**
|
||||||
|
- 📷 **Images**: JPG, PNG, GIF, SVG, WebP, BMP
|
||||||
|
- 📄 **PDFs**: Full document viewer
|
||||||
|
- 🎬 **Videos**: MP4, AVI, MOV
|
||||||
|
- 🎵 **Audio**: MP3, WAV, OGG
|
||||||
|
|
||||||
|
### ⚠️ **Online Preview (via Microsoft Office)**
|
||||||
|
- 📊 **PowerPoint**: PPTX, PPT
|
||||||
|
- 📝 **Word**: DOCX, DOC
|
||||||
|
- 📈 **Excel**: XLSX, XLS
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### For Visitors on Event/Activity Pages
|
||||||
|
|
||||||
|
1. **View Attachments**: Scroll to the "Přílohy" (Attachments) section
|
||||||
|
2. **Preview**: Click "Náhled" button to see the file in a modal
|
||||||
|
3. **Download**: Click "Stáhnout" to download the file
|
||||||
|
4. **Office Docs**: Click "Zobrazit online" to view in browser
|
||||||
|
|
||||||
|
### For Images
|
||||||
|
- Images display inline on the page (max 400px height)
|
||||||
|
- Click the image to see full-size in modal
|
||||||
|
- Zoom and view details
|
||||||
|
|
||||||
|
### For PDFs
|
||||||
|
- Opens in browser's PDF viewer
|
||||||
|
- Scroll through pages
|
||||||
|
- Native browser controls
|
||||||
|
|
||||||
|
### For Videos
|
||||||
|
- HTML5 video player
|
||||||
|
- Play, pause, volume controls
|
||||||
|
- Full-screen option
|
||||||
|
|
||||||
|
### For PowerPoint/Word/Excel
|
||||||
|
- Shows file icon and info
|
||||||
|
- "Stáhnout" - Direct download
|
||||||
|
- "Zobrazit online" - Opens in Microsoft Office Online Viewer
|
||||||
|
- Works without Office installed
|
||||||
|
- View-only mode
|
||||||
|
- Requires internet connection
|
||||||
|
|
||||||
|
## User Experience Examples
|
||||||
|
|
||||||
|
### Example 1: Photo Gallery
|
||||||
|
```
|
||||||
|
[Event Page]
|
||||||
|
├── Photos (shown inline)
|
||||||
|
│ ├── Photo1.jpg [Click to enlarge] [Download]
|
||||||
|
│ ├── Photo2.jpg [Click to enlarge] [Download]
|
||||||
|
│ └── Photo3.png [Click to enlarge] [Download]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Meeting Documents
|
||||||
|
```
|
||||||
|
[Event Page]
|
||||||
|
├── Attachments
|
||||||
|
│ ├── 📊 Presentation.pptx [Preview] [Download] [View Online]
|
||||||
|
│ ├── 📄 Minutes.pdf [Preview] [Download]
|
||||||
|
│ └── 📝 Agenda.docx [Download] [View Online]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Training Video
|
||||||
|
```
|
||||||
|
[Event Page]
|
||||||
|
├── Attachments
|
||||||
|
│ └── 🎬 Training-Session.mp4 [Preview] [Download]
|
||||||
|
[When previewed: Video player with play/pause controls]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where to Find It
|
||||||
|
|
||||||
|
### Frontend Pages
|
||||||
|
- **Activity Detail Page** (`/aktivita/{id}`)
|
||||||
|
- Displays all event attachments with previews
|
||||||
|
- Located below the event description
|
||||||
|
|
||||||
|
### Future: Can be added to
|
||||||
|
- News articles with attachments
|
||||||
|
- Gallery pages
|
||||||
|
- Download sections
|
||||||
|
- Resource pages
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Component Path
|
||||||
|
`frontend/src/components/common/FilePreview.tsx`
|
||||||
|
|
||||||
|
### Usage in Code
|
||||||
|
```tsx
|
||||||
|
import FilePreview from '../components/common/FilePreview';
|
||||||
|
|
||||||
|
<FilePreview
|
||||||
|
url="/uploads/2025/10/file.pptx"
|
||||||
|
name="Presentation.pptx"
|
||||||
|
mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
size={1900000}
|
||||||
|
showInline={false}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
✅ No download required to view content
|
||||||
|
✅ Faster browsing experience
|
||||||
|
✅ View files on any device
|
||||||
|
✅ Office documents without Office installed
|
||||||
|
✅ Better mobile experience
|
||||||
|
|
||||||
|
### For Admins
|
||||||
|
✅ Professional presentation
|
||||||
|
✅ Reduced support requests
|
||||||
|
✅ Better engagement
|
||||||
|
✅ Modern user experience
|
||||||
|
|
||||||
|
## Office Online Viewer
|
||||||
|
|
||||||
|
The "Zobrazit online" feature uses Microsoft's free service:
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. User clicks "Zobrazit online"
|
||||||
|
2. Opens: `https://view.officeapps.live.com/op/view.aspx?src=[YOUR_FILE_URL]`
|
||||||
|
3. Microsoft servers fetch your file
|
||||||
|
4. Render it in browser
|
||||||
|
5. View-only mode (no editing)
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- File must be publicly accessible (not password-protected)
|
||||||
|
- Internet connection required
|
||||||
|
- Works on all modern browsers
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Image failed to load"
|
||||||
|
- Check file URL is accessible
|
||||||
|
- Verify file permissions
|
||||||
|
- Check file format is supported
|
||||||
|
|
||||||
|
### "PDF won't display"
|
||||||
|
- Try downloading the file
|
||||||
|
- Check browser supports PDF viewing
|
||||||
|
- Clear browser cache
|
||||||
|
|
||||||
|
### "Office document won't open online"
|
||||||
|
- Verify internet connection
|
||||||
|
- Check file is publicly accessible
|
||||||
|
- Try downloading instead
|
||||||
|
- Some firewalls block Microsoft services
|
||||||
|
|
||||||
|
### Video won't play
|
||||||
|
- Check browser supports the video format
|
||||||
|
- Try a different browser
|
||||||
|
- Download and use media player
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Test it out**: Go to any event with attachments
|
||||||
|
2. **Upload various file types**: Try images, PDFs, PPTX
|
||||||
|
3. **Check mobile**: Test on phone/tablet
|
||||||
|
4. **Collect feedback**: See what users think
|
||||||
|
|
||||||
|
## Combining with File Tracking
|
||||||
|
|
||||||
|
This preview feature works perfectly with the file tracking system:
|
||||||
|
|
||||||
|
1. **Upload files** via admin panel
|
||||||
|
2. **Attach to events** with proper metadata
|
||||||
|
3. **Files are tracked** (usage count updates)
|
||||||
|
4. **Users get previews** on frontend
|
||||||
|
5. **Admins can manage** files efficiently
|
||||||
|
|
||||||
|
Both systems work together for a complete file management solution!
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# File Tracking Enhancement - Event & Article Attachments
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The file tracking system was not properly tracking event attachments and article attachments, causing files like PPTX documents to show "0 usage" even when they were actively being used in events or articles.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
1. **Event Attachments**: The `TrackEventFiles` function in `file_tracker.go` only tracked `image_url` and `file_url` fields, but completely ignored the `Attachments` array that stores multiple file attachments per event.
|
||||||
|
|
||||||
|
2. **Article Attachments**: The `TrackArticleFiles` function attempted to parse the attachments JSON but had a bug where all attachment field names were set to `"attachments"`, causing them to overwrite each other in the map (only the last attachment would be tracked).
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
#### 1. Enhanced `file_tracker.go`
|
||||||
|
- **Added imports**: `encoding/json` and `path/filepath` for better parsing and filename extraction
|
||||||
|
- **Fixed `TrackArticleFiles`**:
|
||||||
|
- Properly parses JSON array of attachment URLs
|
||||||
|
- Generates unique field names using the filename: `attachment_[filename]`
|
||||||
|
- Handles duplicate filenames by appending suffixes
|
||||||
|
- Falls back to comma-separated parsing if JSON parsing fails
|
||||||
|
|
||||||
|
- **Fixed `TrackEventFiles`**:
|
||||||
|
- Iterates through all event attachments
|
||||||
|
- Generates field names from attachment name or filename
|
||||||
|
- Ensures unique field names using counters
|
||||||
|
- Properly tracks each attachment URL separately
|
||||||
|
|
||||||
|
#### 2. New Admin Endpoint - `RefreshFileTracking`
|
||||||
|
Location: `internal/controllers/files_controller.go`
|
||||||
|
|
||||||
|
- **Purpose**: Re-scans all entities and updates file usage tracking
|
||||||
|
- **Route**: `POST /api/v1/admin/files/refresh-tracking`
|
||||||
|
- **Optional parameter**: `entity_type` (article, event, player, sponsor, contact, team, settings)
|
||||||
|
- **Returns**: Statistics of scanned entities by type
|
||||||
|
- **Features**:
|
||||||
|
- Scans all entities in the database
|
||||||
|
- Updates file usage records for each entity
|
||||||
|
- Can target specific entity types or scan all
|
||||||
|
- Provides detailed statistics on completion
|
||||||
|
|
||||||
|
#### 3. Enhanced MIME Type Detection
|
||||||
|
Expanded `detectMimeType` function to support many more file types including:
|
||||||
|
- Office documents (DOCX, XLSX, PPTX, PPT, DOC, XLS)
|
||||||
|
- Archives (ZIP, RAR, 7Z, TAR, GZ)
|
||||||
|
- Media files (MP4, AVI, MOV, MP3, WAV)
|
||||||
|
- Additional image formats (BMP, ICO)
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
#### 1. New API Service Function
|
||||||
|
Location: `frontend/src/services/files.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const refreshFileTracking = async (entityType?: string)
|
||||||
|
```
|
||||||
|
- Calls the new backend endpoint
|
||||||
|
- Returns statistics of scanned entities
|
||||||
|
|
||||||
|
#### 2. Enhanced Files Admin Page
|
||||||
|
Location: `frontend/src/pages/admin/FilesAdminPage.tsx`
|
||||||
|
|
||||||
|
- **New Button**: "Aktualizovat sledování" (Refresh Tracking)
|
||||||
|
- Green outline style to differentiate from scan button
|
||||||
|
- Located next to the existing "Skenovat soubory" button
|
||||||
|
- Shows loading state during operation
|
||||||
|
|
||||||
|
- **New Modal**: Displays refresh tracking results
|
||||||
|
- Shows success message
|
||||||
|
- Displays statistics for each entity type:
|
||||||
|
- Articles (Články)
|
||||||
|
- Activities/Events (Aktivity)
|
||||||
|
- Players (Hráči)
|
||||||
|
- Sponsors (Sponzoři)
|
||||||
|
- Contacts (Kontakty)
|
||||||
|
- Teams (Týmy)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### File Tracking Flow
|
||||||
|
1. When an event/article is created or updated, the tracking function is called
|
||||||
|
2. The function extracts all file URLs (images, files, attachments)
|
||||||
|
3. For each URL, it creates a unique field name
|
||||||
|
4. The `UpdateFileUsages` function manages the file_usages table:
|
||||||
|
- Adds new usage records
|
||||||
|
- Removes old usage records no longer present
|
||||||
|
- Updates existing records if URLs change
|
||||||
|
|
||||||
|
### Field Naming Convention
|
||||||
|
- Main fields: `image_url`, `file_url`, `og_image_url`, `logo_url`
|
||||||
|
- Attachments: `attachment_[filename]`
|
||||||
|
- Duplicates: `attachment_[filename]_a`, `attachment_[filename]_b`, etc.
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
### For Existing Files (Your Case)
|
||||||
|
1. Go to Admin → Files Management
|
||||||
|
2. Click the green "Aktualizovat sledování" (Refresh Tracking) button
|
||||||
|
3. Wait for the process to complete
|
||||||
|
4. A modal will show statistics of how many entities were scanned
|
||||||
|
5. Refresh the files list to see updated usage counts
|
||||||
|
|
||||||
|
### For New Files
|
||||||
|
File tracking happens automatically when:
|
||||||
|
- Creating a new event with attachments
|
||||||
|
- Updating an event's attachments
|
||||||
|
- Creating/updating articles with attachments
|
||||||
|
- Any entity with file references is saved
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `internal/services/file_tracker.go` - Fixed attachment tracking logic
|
||||||
|
- `internal/controllers/files_controller.go` - Added RefreshFileTracking endpoint, enhanced MIME detection
|
||||||
|
- `internal/routes/routes.go` - Added new route
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/src/services/files.ts` - Added refreshFileTracking function
|
||||||
|
- `frontend/src/pages/admin/FilesAdminPage.tsx` - Added refresh button and result modal
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Accurate Usage Tracking**: All attachments are now properly tracked
|
||||||
|
2. **Better File Management**: Admins can see which files are truly unused
|
||||||
|
3. **Prevents Accidental Deletion**: Files in use will be protected
|
||||||
|
4. **Easy Maintenance**: One-click refresh of all tracking data
|
||||||
|
5. **Comprehensive MIME Support**: Better file type detection for various formats
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. Create an event with multiple PPTX attachments
|
||||||
|
2. Check the files admin page - usage count should be > 0
|
||||||
|
3. Click "Aktualizovat sledování" to refresh tracking
|
||||||
|
4. Verify all attachment types are tracked correctly
|
||||||
|
5. Try deleting a file that's in use - should show warning with usage info
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Quick Fix Guide - PPTX Files Showing 0 Usage
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
Your PPTX file `20251016-153526-9c6b119b0ea8b3b0fca205fb5e80cfbb.pptx` shows 0 usage even though it's being used in an event attachment.
|
||||||
|
|
||||||
|
## Why This Happened
|
||||||
|
The file tracking system wasn't tracking event attachments - only the main image_url and file_url fields. The attachments array was being ignored.
|
||||||
|
|
||||||
|
## Immediate Solution
|
||||||
|
|
||||||
|
### Step 1: Rebuild & Restart Backend
|
||||||
|
```powershell
|
||||||
|
# In your project root directory
|
||||||
|
cd c:\Users\conta\Downloads\PROG+HTML\Fotbal\fotbal-club
|
||||||
|
|
||||||
|
# Rebuild the Go backend
|
||||||
|
go build -o bin/fotbal-club.exe
|
||||||
|
|
||||||
|
# Restart the backend server
|
||||||
|
# Stop the current server (Ctrl+C if running)
|
||||||
|
# Then start it again
|
||||||
|
.\bin\fotbal-club.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Rebuild Frontend (if needed)
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Use the New Refresh Button
|
||||||
|
1. Log in to your admin panel
|
||||||
|
2. Go to **Správa souborů** (Files Management)
|
||||||
|
3. Click the green **"Aktualizovat sledování"** button
|
||||||
|
4. Wait for completion - you'll see a modal with statistics
|
||||||
|
5. Close the modal and check your files list
|
||||||
|
6. The PPTX file should now show usage count > 0
|
||||||
|
|
||||||
|
## What Was Fixed
|
||||||
|
|
||||||
|
### Backend Fixes
|
||||||
|
1. **Event attachment tracking** - Now properly scans all attachments in events
|
||||||
|
2. **Article attachment tracking** - Fixed bug where only one attachment was tracked
|
||||||
|
3. **New admin endpoint** - `/api/v1/admin/files/refresh-tracking` to re-scan everything
|
||||||
|
4. **Better MIME detection** - Added PPTX, DOCX, XLSX, and many other file types
|
||||||
|
|
||||||
|
### Frontend Fixes
|
||||||
|
1. **New refresh button** - Green "Aktualizovat sledování" button
|
||||||
|
2. **Result modal** - Shows statistics after refresh
|
||||||
|
3. **Better UX** - Two separate buttons: Scan files vs Refresh tracking
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After running the refresh, verify the fix:
|
||||||
|
1. Go to Files Management
|
||||||
|
2. Search for your PPTX file: `20251016-153526-9c6b119b0ea8b3b0fca205fb5e80cfbb.pptx`
|
||||||
|
3. Check the "Použití" (Usage) column - should now show 1 or more
|
||||||
|
4. Click on the usage count to see where it's being used
|
||||||
|
5. Should show the event details
|
||||||
|
|
||||||
|
## Future Prevention
|
||||||
|
The fix is now permanent. All new events/articles with attachments will be automatically tracked correctly. You only need to use the "Refresh" button if:
|
||||||
|
- You have old data from before this fix
|
||||||
|
- You manually edit the database
|
||||||
|
- You suspect tracking data is out of sync
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### If the usage still shows 0:
|
||||||
|
1. Check if the event still exists and has the attachment
|
||||||
|
2. Verify the attachment URL matches the file URL exactly
|
||||||
|
3. Check server logs for any errors during refresh
|
||||||
|
4. Try refreshing just events: Add `?entity_type=event` to the API call
|
||||||
|
|
||||||
|
### If you get errors:
|
||||||
|
1. Check that the backend compiled successfully (no Go errors)
|
||||||
|
2. Verify the database migrations ran (the file_usages table exists)
|
||||||
|
3. Check that you're logged in as admin
|
||||||
|
4. Look at browser console for API errors
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
See `FILE_TRACKING_ENHANCEMENT.md` for complete technical documentation.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Navigation Admin 500 Error Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Navigation admin editing was not working - both creating and updating navigation items resulted in a 500 Internal Server Error:
|
||||||
|
```
|
||||||
|
POST http://localhost:8080/api/v1/admin/navigation 500 (Internal Server Error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigation items were not being saved or populated in either the frontend or admin page.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
The issue was in the `CreateNavigationItem` function in `internal/controllers/navigation_controller.go`.
|
||||||
|
|
||||||
|
When calculating the display order for new navigation items (line 109-116), the original code only considered items with `parent_id IS NULL`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if item.DisplayOrder == 0 {
|
||||||
|
var maxOrder int
|
||||||
|
nc.DB.Model(&models.NavigationItem{}).
|
||||||
|
Where("parent_id IS NULL"). // ← Only checks top-level items
|
||||||
|
Select("COALESCE(MAX(display_order), -1) + 1").
|
||||||
|
Scan(&maxOrder)
|
||||||
|
item.DisplayOrder = maxOrder
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issues with this approach:
|
||||||
|
1. **Parent ID not considered**: When creating a child item (with a parent_id), it would still calculate the max order from top-level items, causing incorrect ordering
|
||||||
|
2. **Admin vs Frontend mixing**: Admin navigation items and frontend navigation items were being mixed together because `requires_admin` wasn't considered
|
||||||
|
3. **Potential conflicts**: This could lead to duplicate display_order values and database constraint violations
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### 1. Fixed Display Order Calculation
|
||||||
|
Updated the `CreateNavigationItem` function to properly consider:
|
||||||
|
- **Parent ID**: Calculate max order within the same parent level
|
||||||
|
- **Admin status**: Separate admin and frontend navigation items
|
||||||
|
- **Proper scoping**: Each level (parent/child) and type (admin/frontend) maintains its own ordering
|
||||||
|
|
||||||
|
```go
|
||||||
|
if item.DisplayOrder == 0 {
|
||||||
|
var maxOrder int
|
||||||
|
query := nc.DB.Model(&models.NavigationItem{})
|
||||||
|
|
||||||
|
// Calculate max order for items at the same level (same parent) and same admin status
|
||||||
|
if item.ParentID == nil {
|
||||||
|
query = query.Where("parent_id IS NULL")
|
||||||
|
} else {
|
||||||
|
query = query.Where("parent_id = ?", *item.ParentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also consider requires_admin to keep frontend and admin items separate
|
||||||
|
query = query.Where("requires_admin = ?", item.RequiresAdmin)
|
||||||
|
|
||||||
|
query.Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxOrder)
|
||||||
|
item.DisplayOrder = maxOrder
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Added Better Error Messages
|
||||||
|
Enhanced error responses to include detailed error information for debugging:
|
||||||
|
|
||||||
|
**CreateNavigationItem**:
|
||||||
|
```go
|
||||||
|
if err := nc.DB.Create(&item).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Failed to create navigation item",
|
||||||
|
"details": err.Error(), // ← Added detailed error
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UpdateNavigationItem**:
|
||||||
|
```go
|
||||||
|
if err := nc.DB.Save(&item).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Failed to update navigation item",
|
||||||
|
"details": err.Error(), // ← Added detailed error
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
- `internal/controllers/navigation_controller.go`
|
||||||
|
- Fixed `CreateNavigationItem` function (lines 108-125)
|
||||||
|
- Enhanced error messages in `CreateNavigationItem` (lines 127-134)
|
||||||
|
- Enhanced error messages in `UpdateNavigationItem` (lines 184-190)
|
||||||
|
|
||||||
|
## Testing Instructions
|
||||||
|
1. Start the backend server
|
||||||
|
2. Navigate to Admin → Navigace (Navigation)
|
||||||
|
3. Test creating a new navigation item:
|
||||||
|
- Click "Přidat hlavní položku" (Add main item)
|
||||||
|
- Fill in the form and save
|
||||||
|
- Verify the item appears in the list
|
||||||
|
4. Test editing an existing item:
|
||||||
|
- Click edit icon on any navigation item
|
||||||
|
- Modify fields and save
|
||||||
|
- Verify changes are saved
|
||||||
|
5. Test creating child items:
|
||||||
|
- Create a dropdown item
|
||||||
|
- Add child items to it
|
||||||
|
- Verify proper ordering
|
||||||
|
|
||||||
|
## Expected Behavior After Fix
|
||||||
|
- ✅ Navigation items save successfully
|
||||||
|
- ✅ No 500 errors when creating or updating items
|
||||||
|
- ✅ Proper ordering maintained for frontend and admin navigation separately
|
||||||
|
- ✅ Child items (dropdown submenu items) order correctly within their parent
|
||||||
|
- ✅ Detailed error messages shown if database issues occur
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
- Frontend: `frontend/src/pages/admin/NavigationAdminPage.tsx`
|
||||||
|
- Service: `frontend/src/services/navigation.ts`
|
||||||
|
- Model: `internal/models/navigation.go`
|
||||||
|
- Routes: `internal/routes/routes.go` (lines 332-340)
|
||||||
|
- Migration: `database/migrations/20251010154600_create_navigation_system.up.sql`
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Thumbnail Preview Feature - Admin Pages
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Added hover-to-preview functionality for thumbnail images in admin tables. Now when you hover over a small thumbnail in Articles, Activities, or Players admin pages, a larger preview pops up automatically.
|
||||||
|
|
||||||
|
## Component
|
||||||
|
|
||||||
|
### ThumbnailPreview
|
||||||
|
Location: `frontend/src/components/common/ThumbnailPreview.tsx`
|
||||||
|
|
||||||
|
A lightweight component that shows a small thumbnail with a popover preview on hover.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- 📸 Small thumbnail in table (default 48×48px)
|
||||||
|
- 🔍 Hover to see larger preview (default 300px wide, max 400px high)
|
||||||
|
- ⚡ Quick preview without clicking
|
||||||
|
- 🎨 Respects color mode (light/dark)
|
||||||
|
- 🚀 Lazy loading for performance
|
||||||
|
- ⏱️ 200ms delay before showing preview (prevents accidental popups)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
```tsx
|
||||||
|
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
|
|
||||||
|
<ThumbnailPreview
|
||||||
|
src="/uploads/2025/10/image.jpg"
|
||||||
|
alt="Article cover"
|
||||||
|
size="48px"
|
||||||
|
previewSize="350px"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ThumbnailPreviewProps {
|
||||||
|
src: string; // Image URL (required)
|
||||||
|
alt: string; // Alt text (required)
|
||||||
|
size?: string; // Thumbnail size (default: '48px')
|
||||||
|
previewSize?: string; // Preview popup width (default: '300px')
|
||||||
|
borderRadius?: string; // Border radius (default: 'md')
|
||||||
|
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
|
||||||
|
// How to fit image (default: 'cover')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integrated Pages
|
||||||
|
|
||||||
|
### 1. Articles Admin Page
|
||||||
|
Location: `frontend/src/pages/admin/ArticlesAdminPage.tsx`
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- Static 48×48px thumbnails
|
||||||
|
- No way to preview images without opening edit modal
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Hover over thumbnail → See 350×400px preview
|
||||||
|
- Quick visual identification of articles
|
||||||
|
- No clicking required
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```tsx
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={assetUrl(article.image_url) || '/dist/img/logo-club-empty.svg'}
|
||||||
|
alt={article.title}
|
||||||
|
size="48px"
|
||||||
|
previewSize="350px"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Activities Admin Page
|
||||||
|
Location: `frontend/src/pages/admin/AdminActivitiesPage.tsx`
|
||||||
|
|
||||||
|
**New Feature:**
|
||||||
|
- Added "Náhled" (Preview) column to the table
|
||||||
|
- Shows event cover images
|
||||||
|
- Fallback to club logo if no image set (with 30% opacity)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- No image column in the table
|
||||||
|
- Couldn't see which events have images
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Visual preview column
|
||||||
|
- Hover for larger view
|
||||||
|
- Easy to identify events with/without images
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```tsx
|
||||||
|
{event.image_url ? (
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={assetUrl(event.image_url)}
|
||||||
|
alt={event.title}
|
||||||
|
size="48px"
|
||||||
|
previewSize="350px"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={clubLogo}
|
||||||
|
alt="No image"
|
||||||
|
boxSize="48px"
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Players Admin Page
|
||||||
|
Location: `frontend/src/pages/admin/PlayersAdminPage.tsx`
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- Static 48×48px player photos
|
||||||
|
- No hover preview
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Hover over photo → See 300×400px preview
|
||||||
|
- Better identification of players
|
||||||
|
- Rounded corners match player photo aesthetic
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```tsx
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={normalizeImageUrl(player.image_url)}
|
||||||
|
alt={`${player.first_name} ${player.last_name}`}
|
||||||
|
size="48px"
|
||||||
|
previewSize="300px"
|
||||||
|
borderRadius="md"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### Interaction Flow
|
||||||
|
1. **Hover** over thumbnail (mouse over)
|
||||||
|
2. **Wait** 200ms (prevents accidental triggers)
|
||||||
|
3. **Preview** appears to the right of thumbnail
|
||||||
|
4. **Move away** → Preview closes after 100ms
|
||||||
|
|
||||||
|
### Visual Feedback
|
||||||
|
- Thumbnail scales up 5% on hover
|
||||||
|
- Box shadow appears on hover
|
||||||
|
- Smooth transitions (0.2s)
|
||||||
|
- Cursor changes to pointer
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Lazy loading on thumbnails
|
||||||
|
- Preview image only loads when needed
|
||||||
|
- Minimal re-renders
|
||||||
|
- Portal-based rendering (no z-index issues)
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### For Admins
|
||||||
|
✅ **Faster workflow** - No need to click to see images
|
||||||
|
✅ **Visual identification** - Quickly spot articles/events by cover image
|
||||||
|
✅ **Quality check** - Verify image quality without opening editor
|
||||||
|
✅ **Better overview** - See which content has images
|
||||||
|
|
||||||
|
### For Content Management
|
||||||
|
✅ **Image audit** - Easily see which articles need images
|
||||||
|
✅ **Consistency check** - Spot images that don't match style
|
||||||
|
✅ **Quick decisions** - Choose articles to feature based on visuals
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Popover Configuration
|
||||||
|
```tsx
|
||||||
|
<Popover
|
||||||
|
trigger="hover" // Show on hover
|
||||||
|
placement="right" // Appears to the right
|
||||||
|
openDelay={200} // 200ms delay before showing
|
||||||
|
closeDelay={100} // 100ms delay before hiding
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Portal Rendering
|
||||||
|
The preview uses `<Portal>` to render at the root level, preventing:
|
||||||
|
- Z-index conflicts
|
||||||
|
- Overflow clipping
|
||||||
|
- Parent container constraints
|
||||||
|
|
||||||
|
### Responsive Behavior
|
||||||
|
- Preview max height: 400px
|
||||||
|
- Preview adapts to available space
|
||||||
|
- Falls back to browser default if no space to the right
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
| Feature | Chrome | Firefox | Safari | Edge |
|
||||||
|
|---------|--------|---------|--------|------|
|
||||||
|
| Hover trigger | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Popover | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Lazy loading | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Portal | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
## Mobile Behavior
|
||||||
|
|
||||||
|
On touch devices:
|
||||||
|
- Hover is triggered by tap
|
||||||
|
- Preview stays visible until user taps elsewhere
|
||||||
|
- First tap = preview
|
||||||
|
- Second tap = navigate/edit (if thumbnail is a link)
|
||||||
|
|
||||||
|
## Customization Examples
|
||||||
|
|
||||||
|
### Larger Preview
|
||||||
|
```tsx
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Large preview"
|
||||||
|
size="64px"
|
||||||
|
previewSize="500px"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Square Fit (for logos)
|
||||||
|
```tsx
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Logo"
|
||||||
|
size="48px"
|
||||||
|
objectFit="contain"
|
||||||
|
borderRadius="none"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Circular Thumbnail
|
||||||
|
```tsx
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Avatar"
|
||||||
|
size="40px"
|
||||||
|
borderRadius="full"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Components
|
||||||
|
|
||||||
|
- **FilePreview** (`FilePreview.tsx`) - Full file preview with modal for attachments
|
||||||
|
- **Image Upload** - Various image upload components in admin pages
|
||||||
|
- **AlbumPhotoPicker** - Zonerama gallery picker
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
|
||||||
|
1. **Zoom controls** - Add +/- buttons in preview
|
||||||
|
2. **Multiple images** - Show gallery of images if multiple
|
||||||
|
3. **Image info** - Display dimensions, file size
|
||||||
|
4. **Edit quick action** - Add "Edit" button in preview
|
||||||
|
5. **Keyboard navigation** - Arrow keys to navigate between previews
|
||||||
|
6. **Full-screen mode** - Click to open full-screen view
|
||||||
|
7. **Crop preview** - Show how image will be cropped
|
||||||
|
8. **Compare mode** - Side-by-side comparison of OG image
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@chakra-ui/react` - UI components (Popover, Portal, Image)
|
||||||
|
- React - Core functionality
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `frontend/src/components/common/ThumbnailPreview.tsx` (NEW)
|
||||||
|
2. `frontend/src/pages/admin/ArticlesAdminPage.tsx` (UPDATED)
|
||||||
|
3. `frontend/src/pages/admin/AdminActivitiesPage.tsx` (UPDATED)
|
||||||
|
4. `frontend/src/pages/admin/PlayersAdminPage.tsx` (UPDATED)
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Hover over article thumbnail - preview appears
|
||||||
|
- [ ] Hover over activity thumbnail - preview appears
|
||||||
|
- [ ] Hover over player photo - preview appears
|
||||||
|
- [ ] Preview shows correct image
|
||||||
|
- [ ] Preview closes when mouse leaves
|
||||||
|
- [ ] Delay prevents accidental triggers
|
||||||
|
- [ ] Preview doesn't get cut off by container
|
||||||
|
- [ ] Dark mode colors look correct
|
||||||
|
- [ ] Fallback images work (no image set)
|
||||||
|
- [ ] Lazy loading works (check network tab)
|
||||||
|
- [ ] Mobile tap behavior works
|
||||||
|
- [ ] No console errors
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Thumbnail Preview - Quick Summary
|
||||||
|
|
||||||
|
## What Was Added? 🎯
|
||||||
|
|
||||||
|
Hover-to-preview functionality for thumbnail images in admin tables!
|
||||||
|
|
||||||
|
## Before vs After
|
||||||
|
|
||||||
|
### Articles Admin
|
||||||
|
```
|
||||||
|
BEFORE:
|
||||||
|
[48x48 thumbnail] Article Title Published Actions
|
||||||
|
→ Static image, no preview
|
||||||
|
|
||||||
|
AFTER:
|
||||||
|
[48x48 thumbnail] Article Title Published Actions
|
||||||
|
↓ hover
|
||||||
|
[350x400 preview popup] ← Shows automatically!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activities Admin (New!)
|
||||||
|
```
|
||||||
|
BEFORE:
|
||||||
|
Title Type Start Time Location Actions
|
||||||
|
→ No image column
|
||||||
|
|
||||||
|
AFTER:
|
||||||
|
[Thumbnail] Title Type Start Time Location Actions
|
||||||
|
↓ hover
|
||||||
|
[350x400 preview popup]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Players Admin
|
||||||
|
```
|
||||||
|
BEFORE:
|
||||||
|
[48x48 photo] Player Name Position Actions
|
||||||
|
→ Static photo, no preview
|
||||||
|
|
||||||
|
AFTER:
|
||||||
|
[48x48 photo] Player Name Position Actions
|
||||||
|
↓ hover
|
||||||
|
[300x400 preview popup]
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Hover** your mouse over any thumbnail
|
||||||
|
2. **Wait** 200ms (prevents accidents)
|
||||||
|
3. **Preview** appears to the right
|
||||||
|
4. **Move away** → Preview disappears
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
✅ **No clicking required** - Just hover!
|
||||||
|
✅ **Instant feedback** - See images immediately
|
||||||
|
✅ **Smart positioning** - Always visible, never clipped
|
||||||
|
✅ **Performance optimized** - Lazy loading
|
||||||
|
✅ **Dark mode support** - Looks great in both themes
|
||||||
|
✅ **Mobile friendly** - Tap to preview on touch devices
|
||||||
|
|
||||||
|
## Component Created
|
||||||
|
|
||||||
|
**ThumbnailPreview** (`frontend/src/components/common/ThumbnailPreview.tsx`)
|
||||||
|
|
||||||
|
Simple to use:
|
||||||
|
```tsx
|
||||||
|
<ThumbnailPreview
|
||||||
|
src="/uploads/image.jpg"
|
||||||
|
alt="Description"
|
||||||
|
size="48px" // Thumbnail size
|
||||||
|
previewSize="350px" // Preview popup size
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits for Admins
|
||||||
|
|
||||||
|
🚀 **Faster workflow** - No need to open edit modal
|
||||||
|
👀 **Visual scanning** - Quickly find articles by image
|
||||||
|
✅ **Quality check** - Verify images look good
|
||||||
|
🎯 **Better decisions** - Choose featured content visually
|
||||||
|
📊 **Image audit** - See which content needs images
|
||||||
|
|
||||||
|
## Pages Updated
|
||||||
|
|
||||||
|
1. ✅ **Articles Admin** - Hover on article cover images
|
||||||
|
2. ✅ **Activities Admin** - NEW image column with hover preview
|
||||||
|
3. ✅ **Players Admin** - Hover on player photos
|
||||||
|
|
||||||
|
## Try It Now!
|
||||||
|
|
||||||
|
1. Go to **Admin → Články** (Articles)
|
||||||
|
2. Hover over any article thumbnail
|
||||||
|
3. See the magic! ✨
|
||||||
|
|
||||||
|
## Technical Highlights
|
||||||
|
|
||||||
|
- Uses Chakra UI Popover
|
||||||
|
- Portal rendering (no z-index issues)
|
||||||
|
- 200ms hover delay
|
||||||
|
- Lazy image loading
|
||||||
|
- Smooth animations
|
||||||
|
- Responsive sizing
|
||||||
|
|
||||||
|
## Combining Features
|
||||||
|
|
||||||
|
This works perfectly with other recent additions:
|
||||||
|
|
||||||
|
**File Tracking** (backend)
|
||||||
|
- Tracks which files are used
|
||||||
|
- Shows usage count
|
||||||
|
- Prevents deletion of used files
|
||||||
|
|
||||||
|
**File Preview** (frontend - events)
|
||||||
|
- Full preview for PDFs, PPTX, videos
|
||||||
|
- Download options
|
||||||
|
- Office online viewer
|
||||||
|
|
||||||
|
**Thumbnail Preview** (frontend - admin)
|
||||||
|
- Quick hover preview in tables
|
||||||
|
- Faster admin workflow
|
||||||
|
- No clicking needed
|
||||||
|
|
||||||
|
Together: **Complete file management system!** 🎉
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
You can now easily reuse `ThumbnailPreview` in other places:
|
||||||
|
|
||||||
|
- Sponsors admin (logo previews)
|
||||||
|
- Contacts admin (photo previews)
|
||||||
|
- Gallery admin (photo previews)
|
||||||
|
- Any table with images!
|
||||||
|
|
||||||
|
Just import and use:
|
||||||
|
```tsx
|
||||||
|
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
|
```
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"items":[],"page":1,"page_size":10,"total":0}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:43Z","last_modified":""}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
[{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"A1A","alias":"SATUM 5. liga mužů","original_name":"SATUM 5. liga mužů","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"C1A","alias":"KALMAN TRADE Krajský přebor starší dorost","original_name":"KALMAN TRADE Krajský přebor starší dorost","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"D1A","alias":"KALMAN TRADE Krajský přebor mladší dorost","original_name":"KALMAN TRADE Krajský přebor mladší dorost","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E1S","alias":"2.MSŽL-U 15 sk. E","original_name":"2.MSŽL-U 15 sk. E","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E2S","alias":"2.MSŽL-U 14 sk. E","original_name":"2.MSŽL-U 14 sk. E","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F1S","alias":"1. liga SpSM-U 13 SEVER","original_name":"1. liga SpSM-U 13 SEVER","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F2S","alias":"1. liga SpSM-U 12 SEVER","original_name":"1. liga SpSM-U 12 SEVER","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"G1D","alias":"Starší přípravka 1+5 sk.D","original_name":"Starší přípravka 1+5 sk.D","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1A","alias":"Okresní přebor mladší přípravky (4+1)","original_name":"Okresní přebor mladší přípravky (4+1)","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1C","alias":"Mladší přípravka 1+4 sk.C","original_name":"Mladší přípravka 1+4 sk.C","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"U1E","alias":"PC U1E U-10 Šumperk","original_name":"PC U1E U-10 Šumperk","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V1C","alias":"PC V1C U-8 Nový Jičín","original_name":"PC V1C U-8 Nový Jičín","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V5B","alias":"PC V5B U-9 Hlučín","original_name":"PC V5B U-9 Hlučín","display_order":0}]
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:43Z","last_modified":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:43Z","last_modified":""}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:46Z","last_modified":""}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:47Z","last_modified":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
null
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"lastUpdated":"2025-10-17T06:28:47Z"}
|
||||||
Vendored
+47
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"baseURL": "http://127.0.0.1:8080/api/v1",
|
||||||
|
"duration_ms": 4404,
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"path": "/articles?page=1\u0026page_size=10\u0026published=true",
|
||||||
|
"file": "articles.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sponsors",
|
||||||
|
"file": "sponsors.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/events/upcoming",
|
||||||
|
"file": "events_upcoming.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/public/team-logo-overrides",
|
||||||
|
"file": "team_logo_overrides.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/competition-aliases",
|
||||||
|
"file": "competition_aliases.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/settings",
|
||||||
|
"file": "settings.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
|
||||||
|
"file": "facr_club_info.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58/table",
|
||||||
|
"file": "facr_tables.json",
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2025-10-17T06:28:47Z"
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"about_html":"","accent_color":"#ffbb00","background_color":"#ffffff","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_logo_url":"http://logoapi.sportcreative.eu/logos/7eacd9f0-bfa0-4928-a9b6-936140168f58?format=svg","club_name":"Fotbalový klub Krnov","club_type":"football","club_url":"https://www.fotbal.cz/souteze/club/club/7eacd9f0-bfa0-4928-a9b6-936140168f58","contact_address":"4271","contact_city":"Kostelany nad Moravou","contact_country":"Česko","contact_email":"info@tdvorak.dev","contact_phone":"+420 778 132 521","contact_zip":"687 38","custom_nav":null,"facebook_url":"https://www.facebook.com/p/FK-Kofola-Krnov-61561103731912/","font_body":"Work Sans","font_heading":"Work Sans","gallery_label":"Fotogalerie","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov/1470757","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":49.0453762,"location_longitude":17.4069424,"map_style":"positron","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","primary_color":"#ffdd00","secondary_color":"#002aff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-12","url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg","title":"Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25","uploaded_at":"2025-10-11","url":"https://www.youtube.com/watch?v=_OsRmfYOXJ4"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg","title":"Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25","uploaded_at":"2025-10-02","url":"https://www.youtube.com/watch?v=h_-TS6oVvKA"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg","title":"Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH","uploaded_at":"2025-09-18","url":"https://www.youtube.com/watch?v=ozH8xE7V458"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg","title":"Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti","uploaded_at":"2025-09-16","url":"https://www.youtube.com/watch?v=nrj6_1IoYoo"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","youtube_url":"https://www.youtube.com/@FCBizoniUH"}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:43Z","last_modified":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:43Z","last_modified":""}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"by_name":{}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-17T06:28:43Z","last_modified":""}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
{"fetched_at":"2025-10-16T16:24:04Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
|
||||||
Vendored
+102
@@ -0,0 +1,102 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"title": "",
|
||||||
|
"url": "",
|
||||||
|
"date": "",
|
||||||
|
"photos_count": 0,
|
||||||
|
"views_count": 0,
|
||||||
|
"photos": null,
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
null
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"fetched_at": "2025-10-16T16:24:13Z",
|
||||||
|
"link": ""
|
||||||
|
}
|
||||||
Vendored
+1076
File diff suppressed because it is too large
Load Diff
@@ -142,13 +142,8 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
|
|||||||
onChange={handleUrlChange}
|
onChange={handleUrlChange}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
|
||||||
Podporované formáty:
|
|
||||||
<Text as="span" fontWeight="semibold" ml={1}>mapy.cz</Text> (mapy.com/en/letecka?x=...&y=...),
|
|
||||||
<Text as="span" fontWeight="semibold" ml={1}>Google Maps</Text> (google.com/maps/place/@lat,lng,zoom)
|
|
||||||
</FormHelperText>
|
|
||||||
<HStack mt={2} spacing={3} fontSize="sm">
|
<HStack mt={2} spacing={3} fontSize="sm">
|
||||||
<Text color="gray.600">Quick links:</Text>
|
<Text color="gray.600">Rychlé odkazy:</Text>
|
||||||
<Link
|
<Link
|
||||||
href="https://mapy.com/cs/"
|
href="https://mapy.com/cs/"
|
||||||
isExternal
|
isExternal
|
||||||
@@ -311,27 +306,6 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Example URLs */}
|
|
||||||
<Box
|
|
||||||
bg={bgColor}
|
|
||||||
p={3}
|
|
||||||
borderRadius="md"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={borderColor}
|
|
||||||
fontSize="sm"
|
|
||||||
>
|
|
||||||
<Text fontWeight="semibold" mb={2}>Příklady podporovaných URL:</Text>
|
|
||||||
<VStack align="start" spacing={1}>
|
|
||||||
<Text fontSize="xs" color="gray.600">
|
|
||||||
<strong>Mapy.cz:</strong><br />
|
|
||||||
mapy.cz/en/letecka?x=17.6996859&y=50.0947150&z=19
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="xs" color="gray.600">
|
|
||||||
<strong>Google Maps:</strong><br />
|
|
||||||
google.com/maps/place/@50.0948669,17.7001456,226m
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const quillRef = useRef<ReactQuill | null>(null);
|
const quillRef = useRef<ReactQuill | null>(null);
|
||||||
const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich');
|
|
||||||
|
|
||||||
// Crop modal state
|
// Crop modal state
|
||||||
const [cropOpen, setCropOpen] = useState(false);
|
const [cropOpen, setCropOpen] = useState(false);
|
||||||
@@ -113,8 +112,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
[{ color: [] }, { background: [] }],
|
[{ color: [] }, { background: [] }],
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
[{ align: [] }],
|
[{ align: [] }],
|
||||||
['link', 'image', 'video'],
|
['link', 'image'],
|
||||||
['blockquote', 'code-block'],
|
['blockquote'],
|
||||||
['clean'],
|
['clean'],
|
||||||
],
|
],
|
||||||
basic: [
|
basic: [
|
||||||
@@ -369,11 +368,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show toolbar and position it
|
// Show toolbar and position it above the image
|
||||||
const rect = img.getBoundingClientRect();
|
const rect = img.getBoundingClientRect();
|
||||||
const editorRect = editor.root.getBoundingClientRect();
|
const editorRect = editor.root.getBoundingClientRect();
|
||||||
|
const scrollTop = editor.root.scrollTop;
|
||||||
|
const toolbarHeight = 400; // Approximate toolbar height
|
||||||
|
|
||||||
|
// Calculate position relative to editor, accounting for scroll
|
||||||
|
let topPos = rect.top - editorRect.top + scrollTop - 60;
|
||||||
|
|
||||||
|
// If toolbar would go above visible area, position it below the image
|
||||||
|
if (topPos < scrollTop) {
|
||||||
|
topPos = rect.bottom - editorRect.top + scrollTop + 10;
|
||||||
|
}
|
||||||
|
|
||||||
setToolbarPosition({
|
setToolbarPosition({
|
||||||
top: rect.top - editorRect.top - 50,
|
top: topPos,
|
||||||
left: rect.left - editorRect.left,
|
left: rect.left - editorRect.left,
|
||||||
});
|
});
|
||||||
setShowImageToolbar(true);
|
setShowImageToolbar(true);
|
||||||
@@ -533,55 +543,52 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Editor Controls */}
|
{/* Editor Controls */}
|
||||||
{!readOnly && (
|
{!readOnly && onImageUpload && (
|
||||||
<HStack mb={2} spacing={2} justify="space-between" flexWrap="wrap">
|
<HStack mb={2} spacing={2} justify="flex-start" flexWrap="wrap">
|
||||||
<ButtonGroup size="sm" isAttached variant="outline">
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
leftIcon={<Type size={16} />}
|
leftIcon={<ImageIcon size={16} />}
|
||||||
variant={editorMode === 'rich' ? 'solid' : 'outline'}
|
colorScheme="purple"
|
||||||
colorScheme={editorMode === 'rich' ? 'blue' : 'gray'}
|
onClick={handleImageUpload}
|
||||||
onClick={() => setEditorMode('rich')}
|
>
|
||||||
>
|
Vložit obrázek
|
||||||
Editor
|
</Button>
|
||||||
</Button>
|
<Text fontSize="xs" color="gray.500">
|
||||||
<Button
|
nebo použijte tlačítko obrázku v nástrojové liště
|
||||||
leftIcon={<Code size={16} />}
|
</Text>
|
||||||
variant={editorMode === 'html' ? 'solid' : 'outline'}
|
|
||||||
colorScheme={editorMode === 'html' ? 'blue' : 'gray'}
|
|
||||||
onClick={() => setEditorMode('html')}
|
|
||||||
>
|
|
||||||
HTML
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
|
|
||||||
{editorMode === 'rich' && onImageUpload && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<ImageIcon size={16} />}
|
|
||||||
colorScheme="purple"
|
|
||||||
onClick={handleImageUpload}
|
|
||||||
>
|
|
||||||
Vložit obrázek
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editorMode === 'rich' ? (
|
<Box
|
||||||
<Box
|
position="relative"
|
||||||
position="relative"
|
borderWidth="1px"
|
||||||
borderWidth="1px"
|
borderColor={borderColor}
|
||||||
borderColor={borderColor}
|
borderRadius="md"
|
||||||
borderRadius="md"
|
overflow="hidden"
|
||||||
overflow="hidden"
|
bg={bgColor}
|
||||||
bg={bgColor}
|
sx={{
|
||||||
sx={{
|
|
||||||
'.ql-toolbar': {
|
'.ql-toolbar': {
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: borderColor,
|
borderColor: borderColor,
|
||||||
bg: hoverBg,
|
bg: hoverBg,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '12px',
|
||||||
'& button': {
|
'& button': {
|
||||||
color: 'gray.700 !important',
|
color: 'gray.700 !important',
|
||||||
|
width: '32px !important',
|
||||||
|
height: '32px !important',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'rgba(49, 130, 206, 0.1) !important',
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
'&.ql-active': {
|
||||||
|
background: 'rgba(49, 130, 206, 0.2) !important',
|
||||||
|
color: '#3182ce !important',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'& .ql-stroke': {
|
'& .ql-stroke': {
|
||||||
stroke: 'gray.700 !important',
|
stroke: 'gray.700 !important',
|
||||||
@@ -589,6 +596,29 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
'& .ql-fill': {
|
'& .ql-fill': {
|
||||||
fill: 'gray.700 !important',
|
fill: 'gray.700 !important',
|
||||||
},
|
},
|
||||||
|
'& .ql-active .ql-stroke': {
|
||||||
|
stroke: '#3182ce !important',
|
||||||
|
},
|
||||||
|
'& .ql-active .ql-fill': {
|
||||||
|
fill: '#3182ce !important',
|
||||||
|
},
|
||||||
|
'& .ql-picker': {
|
||||||
|
color: 'gray.700 !important',
|
||||||
|
},
|
||||||
|
'& .ql-picker-label': {
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'rgba(49, 130, 206, 0.1) !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .ql-picker-options': {
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
padding: '8px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'.ql-container': {
|
'.ql-container': {
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
@@ -601,6 +631,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
bg: 'white !important',
|
bg: 'white !important',
|
||||||
color: 'gray.800 !important',
|
color: 'gray.800 !important',
|
||||||
|
padding: '16px',
|
||||||
|
lineHeight: '1.6',
|
||||||
'&::-webkit-scrollbar': {
|
'&::-webkit-scrollbar': {
|
||||||
width: '8px',
|
width: '8px',
|
||||||
},
|
},
|
||||||
@@ -611,6 +643,27 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
bg: 'gray.400',
|
bg: 'gray.400',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
},
|
},
|
||||||
|
'h1': {
|
||||||
|
fontSize: '2em !important',
|
||||||
|
fontWeight: 'bold !important',
|
||||||
|
marginTop: '0.67em !important',
|
||||||
|
marginBottom: '0.67em !important',
|
||||||
|
lineHeight: '1.2 !important',
|
||||||
|
},
|
||||||
|
'h2': {
|
||||||
|
fontSize: '1.5em !important',
|
||||||
|
fontWeight: 'bold !important',
|
||||||
|
marginTop: '0.83em !important',
|
||||||
|
marginBottom: '0.83em !important',
|
||||||
|
lineHeight: '1.3 !important',
|
||||||
|
},
|
||||||
|
'h3': {
|
||||||
|
fontSize: '1.17em !important',
|
||||||
|
fontWeight: 'bold !important',
|
||||||
|
marginTop: '1em !important',
|
||||||
|
marginBottom: '1em !important',
|
||||||
|
lineHeight: '1.4 !important',
|
||||||
|
},
|
||||||
img: {
|
img: {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -652,26 +705,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
as="textarea"
|
|
||||||
value={value}
|
|
||||||
onChange={(e: any) => onChange(e.target.value)}
|
|
||||||
fontFamily="mono"
|
|
||||||
fontSize="sm"
|
|
||||||
p={4}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={borderColor}
|
|
||||||
borderRadius="md"
|
|
||||||
bg={bgColor}
|
|
||||||
resize="vertical"
|
|
||||||
minH={height}
|
|
||||||
maxH="70vh"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!readOnly && editorMode === 'rich' && (
|
{!readOnly && (
|
||||||
<Text fontSize="xs" color="gray.500" mt={2}>
|
<Text fontSize="xs" color="gray.500" mt={2}>
|
||||||
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
|
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -684,14 +719,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
top={`${toolbarPosition.top}px`}
|
top={`${toolbarPosition.top}px`}
|
||||||
left={`${toolbarPosition.left}px`}
|
left={`${toolbarPosition.left}px`}
|
||||||
bg={toolbarBg}
|
bg={toolbarBg}
|
||||||
borderWidth="1px"
|
borderWidth="2px"
|
||||||
borderColor={toolbarBorder}
|
borderColor="blue.400"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
boxShadow="lg"
|
boxShadow="2xl"
|
||||||
p={3}
|
p={4}
|
||||||
zIndex={1500}
|
zIndex={9999}
|
||||||
minW="320px"
|
minW="340px"
|
||||||
maxW="400px"
|
maxW="420px"
|
||||||
|
pointerEvents="auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
{/* Toolbar Header */}
|
{/* Toolbar Header */}
|
||||||
@@ -894,7 +931,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref={imgRef as any}
|
ref={imgRef as any}
|
||||||
src={cropSrc}
|
src={cropSrc || ''}
|
||||||
alt="Crop preview"
|
alt="Crop preview"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
Link as ChakraLink,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalFooter,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
Image,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
useColorModeValue,
|
||||||
|
AspectRatio,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
FiDownload,
|
||||||
|
FiEye,
|
||||||
|
FiFile,
|
||||||
|
FiFileText,
|
||||||
|
FiImage,
|
||||||
|
FiVideo,
|
||||||
|
FiMusic,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
|
export interface FilePreviewProps {
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
size?: number;
|
||||||
|
showInline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
mimeType = '',
|
||||||
|
size,
|
||||||
|
showInline = false,
|
||||||
|
}) => {
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const fullUrl = assetUrl(url) || url;
|
||||||
|
const fileName = name || url.split('/').pop() || 'file';
|
||||||
|
const mime = mimeType.toLowerCase();
|
||||||
|
|
||||||
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
|
const mutedText = useColorModeValue('gray.600', 'gray.300');
|
||||||
|
const linkColor = useColorModeValue('blue.600', 'blue.300');
|
||||||
|
|
||||||
|
// Determine file type and icon
|
||||||
|
const getFileInfo = () => {
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return { type: 'image', icon: FiImage, color: 'purple.500', canPreview: true };
|
||||||
|
}
|
||||||
|
if (mime === 'application/pdf') {
|
||||||
|
return { type: 'pdf', icon: FiFileText, color: 'red.500', canPreview: true };
|
||||||
|
}
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
return { type: 'video', icon: FiVideo, color: 'pink.500', canPreview: true };
|
||||||
|
}
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
return { type: 'audio', icon: FiMusic, color: 'green.500', canPreview: true };
|
||||||
|
}
|
||||||
|
if (mime.includes('word') || mime.includes('document')) {
|
||||||
|
return { type: 'document', icon: FiFileText, color: 'blue.500', canPreview: false };
|
||||||
|
}
|
||||||
|
if (mime.includes('sheet') || mime.includes('excel')) {
|
||||||
|
return { type: 'spreadsheet', icon: FiFile, color: 'green.600', canPreview: false };
|
||||||
|
}
|
||||||
|
if (mime.includes('presentation') || mime.includes('powerpoint')) {
|
||||||
|
return { type: 'presentation', icon: FiFile, color: 'orange.500', canPreview: false };
|
||||||
|
}
|
||||||
|
return { type: 'other', icon: FiFile, color: 'gray.500', canPreview: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileInfo = getFileInfo();
|
||||||
|
const sizeKB = typeof size === 'number' ? Math.round(size / 1024) : undefined;
|
||||||
|
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
|
||||||
|
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
|
||||||
|
|
||||||
|
// Render preview content based on file type
|
||||||
|
const renderPreviewContent = () => {
|
||||||
|
if (fileInfo.type === 'image') {
|
||||||
|
if (imageError) {
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} py={10}>
|
||||||
|
<Icon as={FiImage} boxSize={12} color="gray.400" />
|
||||||
|
<Text color={mutedText}>Obrázek se nepodařilo načíst</Text>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiDownload />}
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
Stáhnout soubor
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={fullUrl}
|
||||||
|
alt={fileName}
|
||||||
|
maxW="100%"
|
||||||
|
maxH="70vh"
|
||||||
|
objectFit="contain"
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInfo.type === 'pdf') {
|
||||||
|
return (
|
||||||
|
<AspectRatio ratio={8.5 / 11} w="100%" minH="70vh">
|
||||||
|
<iframe
|
||||||
|
src={`${fullUrl}#view=FitH`}
|
||||||
|
title={fileName}
|
||||||
|
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInfo.type === 'video') {
|
||||||
|
return (
|
||||||
|
<AspectRatio ratio={16 / 9} w="100%">
|
||||||
|
<video controls style={{ width: '100%', height: '100%' }}>
|
||||||
|
<source src={fullUrl} type={mime} />
|
||||||
|
Váš prohlížeč nepodporuje přehrávání videa.
|
||||||
|
</video>
|
||||||
|
</AspectRatio>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInfo.type === 'audio') {
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} py={10}>
|
||||||
|
<Icon as={FiMusic} boxSize={12} color={fileInfo.color} />
|
||||||
|
<audio controls style={{ width: '100%', maxWidth: '500px' }}>
|
||||||
|
<source src={fullUrl} type={mime} />
|
||||||
|
Váš prohlížeč nepodporuje přehrávání zvuku.
|
||||||
|
</audio>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Office documents, show info and download option
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} py={10}>
|
||||||
|
<Icon as={fileInfo.icon} boxSize={16} color={fileInfo.color} />
|
||||||
|
<VStack spacing={2}>
|
||||||
|
<Text fontSize="lg" fontWeight="medium">{fileName}</Text>
|
||||||
|
{sizeStr && <Badge colorScheme="gray">{sizeStr}</Badge>}
|
||||||
|
<Text color={mutedText} fontSize="sm" textAlign="center">
|
||||||
|
{fileInfo.type === 'presentation' && 'PowerPoint prezentace'}
|
||||||
|
{fileInfo.type === 'document' && 'Word dokument'}
|
||||||
|
{fileInfo.type === 'spreadsheet' && 'Excel tabulka'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiDownload />}
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
Stáhnout
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fullUrl)}`}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiEye />}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Zobrazit online
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={mutedText}>
|
||||||
|
Pro zobrazení .pptx, .docx, .xlsx můžete použít "Zobrazit online"
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline preview for images
|
||||||
|
if (showInline && fileInfo.type === 'image') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
bg={cardBg}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={fullUrl}
|
||||||
|
alt={fileName}
|
||||||
|
w="100%"
|
||||||
|
maxH="400px"
|
||||||
|
objectFit="cover"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={onOpen}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
{!imageError && (
|
||||||
|
<HStack justify="space-between" p={3} borderTopWidth="1px">
|
||||||
|
<Text fontSize="sm" color={mutedText} isTruncated maxW="60%">
|
||||||
|
{fileName}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen}>
|
||||||
|
Náhled
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<FiDownload />}
|
||||||
|
>
|
||||||
|
Stáhnout
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact button view
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HStack
|
||||||
|
justify="space-between"
|
||||||
|
p={3}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
bg={cardBg}
|
||||||
|
>
|
||||||
|
<HStack flex={1} minW={0}>
|
||||||
|
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
|
||||||
|
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||||
|
<ChakraLink
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
color={linkColor}
|
||||||
|
fontWeight="medium"
|
||||||
|
isTruncated
|
||||||
|
maxW="100%"
|
||||||
|
_hover={{ textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</ChakraLink>
|
||||||
|
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={2} flexShrink={0}>
|
||||||
|
{fileInfo.canPreview && (
|
||||||
|
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen} variant="outline">
|
||||||
|
Náhled
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FiDownload />}
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
Stáhnout
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Preview Modal */}
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||||
|
<ModalOverlay bg="blackAlpha.800" />
|
||||||
|
<ModalContent maxW="90vw" maxH="90vh">
|
||||||
|
<ModalHeader>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<VStack align="start" spacing={0}>
|
||||||
|
<Text>{fileName}</Text>
|
||||||
|
{sizeStr && <Text fontSize="sm" fontWeight="normal" color={mutedText}>{sizeStr}</Text>}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody pb={6} overflow="auto">
|
||||||
|
{renderPreviewContent()}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiDownload />}
|
||||||
|
colorScheme="blue"
|
||||||
|
mr={3}
|
||||||
|
>
|
||||||
|
Stáhnout
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Image,
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverBody,
|
||||||
|
useColorModeValue,
|
||||||
|
Portal,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
export interface ThumbnailPreviewProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
size?: string;
|
||||||
|
previewSize?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThumbnailPreview - Small thumbnail image with hover to show larger preview
|
||||||
|
* Perfect for admin table rows where you want to see images without clicking
|
||||||
|
*/
|
||||||
|
const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
size = '48px',
|
||||||
|
previewSize = '300px',
|
||||||
|
borderRadius = 'md',
|
||||||
|
objectFit = 'cover',
|
||||||
|
}) => {
|
||||||
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
|
const bgColor = useColorModeValue('white', 'gray.800');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover trigger="hover" placement="right" openDelay={200} closeDelay={100}>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Box
|
||||||
|
cursor="pointer"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
boxSize={size}
|
||||||
|
objectFit={objectFit}
|
||||||
|
borderRadius={borderRadius}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<Portal>
|
||||||
|
<PopoverContent
|
||||||
|
width={previewSize}
|
||||||
|
borderColor={borderColor}
|
||||||
|
boxShadow="2xl"
|
||||||
|
bg={bgColor}
|
||||||
|
_focus={{ boxShadow: '2xl' }}
|
||||||
|
>
|
||||||
|
<PopoverBody p={0}>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={`${alt} - preview`}
|
||||||
|
width="100%"
|
||||||
|
maxH="400px"
|
||||||
|
objectFit="contain"
|
||||||
|
borderRadius="md"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Portal>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThumbnailPreview;
|
||||||
@@ -118,6 +118,9 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const clubTheme = useClubTheme();
|
const clubTheme = useClubTheme();
|
||||||
|
|
||||||
|
// Early return if not admin - MUST be before any other hooks
|
||||||
|
if (!isAdmin) return null;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [configs, setConfigs] = useState<PageElementConfig[]>([]);
|
const [configs, setConfigs] = useState<PageElementConfig[]>([]);
|
||||||
const [localChanges, setLocalChanges] = useState<Record<string, string>>({});
|
const [localChanges, setLocalChanges] = useState<Record<string, string>>({});
|
||||||
@@ -148,7 +151,6 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
|
|
||||||
// Auto-activate editing mode if URL parameter is present
|
// Auto-activate editing mode if URL parameter is present
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) return;
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('myuibrix') === 'edit') {
|
if (params.get('myuibrix') === 'edit') {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -164,12 +166,11 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
isClosable: true,
|
isClosable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isAdmin, toast]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load configurations
|
// Load configurations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) return;
|
|
||||||
|
|
||||||
const loadConfigs = async () => {
|
const loadConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getPageElementConfigs(pageType);
|
const data = await getPageElementConfigs(pageType);
|
||||||
@@ -225,7 +226,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
}, [pageType, isAdmin]);
|
}, [pageType]);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -630,8 +631,6 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
|
|
||||||
const currentVariants = selectedElement ? ELEMENT_VARIANTS[selectedElement] : [];
|
const currentVariants = selectedElement ? ELEMENT_VARIANTS[selectedElement] : [];
|
||||||
const currentVariant = selectedElement ? (localChanges[selectedElement] || currentVariants[0]?.value) : null;
|
const currentVariant = selectedElement ? (localChanges[selectedElement] || currentVariants[0]?.value) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import DOMPurify from 'dompurify';
|
|||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
import EventLocationMap from '../components/events/EventLocationMap';
|
import EventLocationMap from '../components/events/EventLocationMap';
|
||||||
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
||||||
|
import FilePreview from '../components/common/FilePreview';
|
||||||
|
|
||||||
const ActivityDetailPage: React.FC = () => {
|
const ActivityDetailPage: React.FC = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -163,32 +164,21 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments with Preview */}
|
||||||
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
||||||
<VStack align="stretch" spacing={2}>
|
<VStack align="stretch" spacing={3}>
|
||||||
<Heading as="h3" size="sm">Přílohy</Heading>
|
<Heading as="h3" size="sm">Přílohy</Heading>
|
||||||
<VStack align="stretch" spacing={2}>
|
<VStack align="stretch" spacing={3}>
|
||||||
{data.attachments.map((att: any, idx: number) => {
|
{data.attachments.map((att: any, idx: number) => (
|
||||||
const sizeKB = typeof att.size === 'number' ? Math.round(att.size / 1024) : undefined;
|
<FilePreview
|
||||||
const mime = String(att.mime_type || '').toLowerCase();
|
key={idx}
|
||||||
const isImg = mime.startsWith('image/');
|
url={att.url}
|
||||||
return (
|
name={att.name}
|
||||||
<HStack key={idx} justify="space-between" p={2.5} borderWidth="1px" borderColor={borderColor} borderRadius="md" bg={cardBg}>
|
mimeType={att.mime_type}
|
||||||
<HStack>
|
size={att.size}
|
||||||
<Icon as={isImg ? FiImage : FiFile} color={isImg ? 'purple.500' : 'gray.600'} />
|
showInline={att.mime_type?.startsWith('image/')}
|
||||||
<ChakraLink href={assetUrl(att.url) || att.url} isExternal color={linkColor} _hover={{ textDecoration: 'underline', color: linkHoverColor }}>
|
/>
|
||||||
{att.name || att.url}
|
))}
|
||||||
</ChakraLink>
|
|
||||||
</HStack>
|
|
||||||
<HStack>
|
|
||||||
{sizeKB && <Text fontSize="xs" color={mutedText}>{sizeKB} kB</Text>}
|
|
||||||
<Button as={ChakraLink} href={assetUrl(att.url) || att.url} isExternal size="sm" leftIcon={<FiDownload />}>
|
|
||||||
Stáhnout
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1367,12 +1367,12 @@ const HomePage: React.FC = () => {
|
|||||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
||||||
<section data-element="hero" className="hero-grid">
|
<section data-element="hero" className="hero-grid">
|
||||||
{news[0] ? (
|
{featured[0] ? (
|
||||||
<a href={`/news/${news[0].slug || news[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(news[0].image) || '/images/news/placeholder.jpg'})` }} />
|
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
|
||||||
<div className="overlay">
|
<div className="overlay">
|
||||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>{news[0].category || 'Aktuality'}</div>
|
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>{featured[0].category || 'Aktuality'}</div>
|
||||||
<h2 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>{news[0].title}</h2>
|
<h2 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>{featured[0].title}</h2>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -1385,7 +1385,7 @@ const HomePage: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<div className="small-col">
|
<div className="small-col">
|
||||||
{news.slice(1, 3).map((n, idx) => (
|
{featured.slice(1, 3).map((n, idx) => (
|
||||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none' }}>
|
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none' }}>
|
||||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
<div className="bg" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||||
<div className="overlay">
|
<div className="overlay">
|
||||||
@@ -1394,14 +1394,14 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, news.length - 1))) }).map((_, idx) => (
|
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
|
||||||
<div key={`placeholder-${idx}`} className="hero-card small" style={{ pointerEvents: 'none' }}>
|
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
|
||||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
||||||
<div className="overlay">
|
<div className="overlay">
|
||||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
|
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
|
||||||
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
|
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1418,20 +1418,7 @@ const HomePage: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Featured articles grid (uses Articles.featured flag) */}
|
{/* Featured articles are now shown in the hero grid above, not here */}
|
||||||
{featured.length > 0 && isVisible('news', true) && (
|
|
||||||
<section data-element="news" className="three-cols" style={{ marginTop: 8 }}>
|
|
||||||
{featured.map((n) => (
|
|
||||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none', height: 220 }}>
|
|
||||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
|
||||||
<div className="overlay">
|
|
||||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Vybrané</div>
|
|
||||||
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>{n.title}</h3>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sidebar banners (homepage_sidebar) */}
|
{/* Sidebar banners (homepage_sidebar) */}
|
||||||
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||||
@@ -1545,27 +1532,7 @@ const HomePage: React.FC = () => {
|
|||||||
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
|
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
|
||||||
</div>
|
</div>
|
||||||
<div className="matches-grid">
|
<div className="matches-grid">
|
||||||
<div className="matches-track"
|
<div className="matches-track" ref={trackRef}>
|
||||||
ref={trackRef}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
const el = e.currentTarget as HTMLDivElement;
|
|
||||||
el.dataset.dragging = '1';
|
|
||||||
el.dataset.startX = String(e.pageX - el.offsetLeft);
|
|
||||||
el.dataset.scrollLeft = String(el.scrollLeft);
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).dataset.dragging = ''; }}
|
|
||||||
onMouseUp={(e) => { (e.currentTarget as HTMLDivElement).dataset.dragging = ''; }}
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
const el = e.currentTarget as HTMLDivElement;
|
|
||||||
if (el.dataset.dragging !== '1') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const startX = Number(el.dataset.startX || 0);
|
|
||||||
const scrollLeft = Number(el.dataset.scrollLeft || 0);
|
|
||||||
const x = e.pageX - el.offsetLeft;
|
|
||||||
const walk = (x - startX) * 1; // scroll-fast factor
|
|
||||||
el.scrollLeft = scrollLeft - walk;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
|
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
|
||||||
const handleMatchClick = (e: React.MouseEvent) => {
|
const handleMatchClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SearchResult } from '../services/facr/types';
|
|||||||
import { extractPalette, pickTextColor, generateJwtSecret, contrastRatio, isContrastAccessible, generateThemeCandidates, ThemeCandidate, adjustForContrast } from '../utils/colors';
|
import { extractPalette, pickTextColor, generateJwtSecret, contrastRatio, isContrastAccessible, generateThemeCandidates, ThemeCandidate, adjustForContrast } from '../utils/colors';
|
||||||
import { clearToken, setHasAdmin } from '../utils/auth';
|
import { clearToken, setHasAdmin } from '../utils/auth';
|
||||||
import ContactMap from '../components/home/ContactMap';
|
import ContactMap from '../components/home/ContactMap';
|
||||||
import { FONT_PAIRINGS, loadGoogleFont, getFontStyleColor } from '../config/fonts';
|
import { FONT_PAIRINGS, applyFontPairing, getFontStyleColor } from '../config/fonts';
|
||||||
import MapLinkImporter from '../components/admin/MapLinkImporter';
|
import MapLinkImporter from '../components/admin/MapLinkImporter';
|
||||||
import MapStyleSelector from '../components/admin/MapStyleSelector';
|
import MapStyleSelector from '../components/admin/MapStyleSelector';
|
||||||
import { MapCoordinates } from '../utils/mapUrlParser';
|
import { MapCoordinates } from '../utils/mapUrlParser';
|
||||||
@@ -165,14 +165,21 @@ const SetupPage: React.FC = () => {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [clubQuery, searchClubs]);
|
}, [clubQuery, searchClubs]);
|
||||||
|
|
||||||
// Load selected font for preview
|
// Load and apply selected font for preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
|
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
|
||||||
if (pairing) {
|
if (pairing) {
|
||||||
loadGoogleFont(pairing.googleFontsUrl);
|
applyFontPairing(pairing);
|
||||||
}
|
}
|
||||||
}, [selectedFont]);
|
}, [selectedFont]);
|
||||||
|
|
||||||
|
// Auto-fill SMTP username from contact email
|
||||||
|
useEffect(() => {
|
||||||
|
if (contactEmail && !smtpUser) {
|
||||||
|
setSmtpUser(contactEmail);
|
||||||
|
}
|
||||||
|
}, [contactEmail, smtpUser]);
|
||||||
|
|
||||||
const handleSelectClub = async (item: SearchResult) => {
|
const handleSelectClub = async (item: SearchResult) => {
|
||||||
const clubIdValue = item.club_id || '';
|
const clubIdValue = item.club_id || '';
|
||||||
setClubId(clubIdValue);
|
setClubId(clubIdValue);
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import ContactMap from '../../components/home/ContactMap';
|
|||||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||||
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
||||||
|
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
const types: Array<{ value: Event['type']; label: string }> = [
|
const types: Array<{ value: Event['type']; label: string }> = [
|
||||||
{ value: 'match', label: 'Zápas' },
|
{ value: 'match', label: 'Zápas' },
|
||||||
@@ -373,6 +375,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
<Table size="sm">
|
<Table size="sm">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
|
<Th>Náhled</Th>
|
||||||
<Th>Název</Th>
|
<Th>Název</Th>
|
||||||
<Th>Typ</Th>
|
<Th>Typ</Th>
|
||||||
<Th>Začátek</Th>
|
<Th>Začátek</Th>
|
||||||
@@ -384,10 +387,28 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Tr><Td colSpan={7}>Načítání…</Td></Tr>
|
<Tr><Td colSpan={8}>Načítání…</Td></Tr>
|
||||||
)}
|
)}
|
||||||
{!isLoading && events.map(ev => (
|
{!isLoading && events.map(ev => (
|
||||||
<Tr key={ev.id}>
|
<Tr key={ev.id}>
|
||||||
|
<Td>
|
||||||
|
{(ev as any).image_url ? (
|
||||||
|
<ThumbnailPreview
|
||||||
|
src={assetUrl((ev as any).image_url) || (ev as any).image_url}
|
||||||
|
alt={ev.title}
|
||||||
|
size="48px"
|
||||||
|
previewSize="350px"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChakraImage
|
||||||
|
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'}
|
||||||
|
alt="No image"
|
||||||
|
boxSize="48px"
|
||||||
|
objectFit="contain"
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
<Td>{ev.title}</Td>
|
<Td>{ev.title}</Td>
|
||||||
<Td>{ev.type}</Td>
|
<Td>{ev.type}</Td>
|
||||||
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
||||||
|
|||||||
@@ -716,89 +716,6 @@ const AnalyticsAdminPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pageviews Chart */}
|
|
||||||
<Card bg={bgColor} borderColor={borderColor}>
|
|
||||||
<CardHeader>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Icon as={FiTrendingUp} color="blue.500" boxSize={5} />
|
|
||||||
<Heading size="md">Zobrazení stránek v čase</Heading>
|
|
||||||
</HStack>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{loading && pageviewsData.length === 0 ? (
|
|
||||||
<Flex justify="center" py={8}>
|
|
||||||
<Spinner size="lg" />
|
|
||||||
</Flex>
|
|
||||||
) : pageviewsData.length === 0 || pageviewsData.every(d => d.value === 0) ? (
|
|
||||||
<Flex justify="center" align="center" direction="column" py={8}>
|
|
||||||
<Icon as={FiTrendingUp} color="gray.300" boxSize={12} mb={3} />
|
|
||||||
<Text color="gray.500" fontWeight="medium">Žádná data pro zobrazení</Text>
|
|
||||||
<Text color="gray.400" fontSize="sm" mt={1}>Pro vybrané časové období nejsou k dispozici žádná data o návštěvnosti</Text>
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<Box height="300px">
|
|
||||||
<Bar
|
|
||||||
data={{
|
|
||||||
labels: pageviewsData.map(d => d.date),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Zobrazení',
|
|
||||||
data: pageviewsData.map(d => d.value),
|
|
||||||
backgroundColor: 'rgba(66, 153, 225, 0.6)',
|
|
||||||
borderColor: 'rgb(66, 153, 225)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
options={{
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
titleColor: '#ffffff',
|
|
||||||
bodyColor: '#ffffff',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
borderWidth: 1,
|
|
||||||
padding: 12,
|
|
||||||
displayColors: false,
|
|
||||||
callbacks: {
|
|
||||||
label: function(context) {
|
|
||||||
return `Zobrazení: ${context.parsed.y}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
color: '#718096',
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.1)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
color: '#718096',
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Country Flags Section */}
|
{/* Country Flags Section */}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, sa
|
|||||||
import { facrApi } from '../../services/facr/facrApi';
|
import { facrApi } from '../../services/facr/facrApi';
|
||||||
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
||||||
import PollLinker from '../../components/admin/PollLinker';
|
import PollLinker from '../../components/admin/PollLinker';
|
||||||
|
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
|
|
||||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||||
|
|
||||||
@@ -30,8 +31,9 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
|||||||
const linkQ = useQuery({
|
const linkQ = useQuery({
|
||||||
queryKey: ['article-match-link', articleId],
|
queryKey: ['article-match-link', articleId],
|
||||||
queryFn: () => getArticleMatchLink(articleId),
|
queryFn: () => getArticleMatchLink(articleId),
|
||||||
enabled: typeof articleId !== 'undefined' && articleId !== null,
|
enabled: typeof articleId !== 'undefined' && articleId !== null && (typeof articleId === 'number' ? articleId > 0 : String(articleId).trim() !== ''),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
const mid = (linkQ.data as any)?.external_match_id;
|
const mid = (linkQ.data as any)?.external_match_id;
|
||||||
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
|
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
|
||||||
@@ -169,12 +171,18 @@ const ArticlesAdminPage = () => {
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||||
const items: any[] = comps.flatMap((c: any) => (Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({
|
const items: any[] = comps.flatMap((c: any) => (Array.isArray(c.matches) ? c.matches : []).map((m: any) => {
|
||||||
id: String(m.match_id || m.id || ''),
|
const score = m.score || (m.result_home!=null&&m.result_away!=null?`${m.result_home}:${m.result_away}`:'vs');
|
||||||
date: m.date_time || m.date || '',
|
return {
|
||||||
label: `${m.date_time || m.date || ''} • ${m.home || m.home_team || ''} ${m.score || (m.result_home!=null&&m.result_away!=null?`${m.result_home}:${m.result_away}`:'vs')} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
|
id: String(m.match_id || m.id || ''),
|
||||||
competition: c?.name || ''
|
date: m.date_time || m.date || '',
|
||||||
})));
|
label: `${m.date_time || m.date || ''} • ${m.home || m.home_team || ''} ${score} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
|
||||||
|
competition: c?.name || '',
|
||||||
|
home: m.home || m.home_team || '',
|
||||||
|
away: m.away || m.away_team || '',
|
||||||
|
score: score
|
||||||
|
};
|
||||||
|
}));
|
||||||
// keep latest 200 for performance
|
// keep latest 200 for performance
|
||||||
setMatchOptions(items.slice(-200).reverse());
|
setMatchOptions(items.slice(-200).reverse());
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -207,7 +215,7 @@ const ArticlesAdminPage = () => {
|
|||||||
const [linkedMatchId, setLinkedMatchId] = useState<string>('');
|
const [linkedMatchId, setLinkedMatchId] = useState<string>('');
|
||||||
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
|
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
|
||||||
const [matchIdInput, setMatchIdInput] = useState<string>('');
|
const [matchIdInput, setMatchIdInput] = useState<string>('');
|
||||||
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string }>>([]);
|
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string; home?: string; away?: string; score?: string }>>([]);
|
||||||
const [matchSearch, setMatchSearch] = useState<string>('');
|
const [matchSearch, setMatchSearch] = useState<string>('');
|
||||||
const [matchDateFilter, setMatchDateFilter] = useState<string>('');
|
const [matchDateFilter, setMatchDateFilter] = useState<string>('');
|
||||||
const [tempMatchLink, setTempMatchLink] = useState<string>(''); // Temporary storage for new articles
|
const [tempMatchLink, setTempMatchLink] = useState<string>(''); // Temporary storage for new articles
|
||||||
@@ -218,12 +226,40 @@ const ArticlesAdminPage = () => {
|
|||||||
const [zLoading, setZLoading] = useState<boolean>(false);
|
const [zLoading, setZLoading] = useState<boolean>(false);
|
||||||
const [albumPickerOpen, setAlbumPickerOpen] = useState<boolean>(false);
|
const [albumPickerOpen, setAlbumPickerOpen] = useState<boolean>(false);
|
||||||
const { isOpen: isAlbumPickerOpen, onOpen: onAlbumPickerOpen, onClose: onAlbumPickerClose } = useDisclosure();
|
const { isOpen: isAlbumPickerOpen, onOpen: onAlbumPickerOpen, onClose: onAlbumPickerClose } = useDisclosure();
|
||||||
|
const { isOpen: isGalleryPickerOpen, onOpen: onGalleryPickerOpen, onClose: onGalleryPickerClose } = useDisclosure();
|
||||||
|
const [cachedAlbums, setCachedAlbums] = useState<Array<{ id: string; date: string; title?: string; photos: Array<{ id: string; image_1500: string; page_url: string }> }>>([]);
|
||||||
|
const [galleryLoading, setGalleryLoading] = useState<boolean>(false);
|
||||||
const [youtubeVideos, setYoutubeVideos] = useState<YouTubeVideo[]>([]);
|
const [youtubeVideos, setYoutubeVideos] = useState<YouTubeVideo[]>([]);
|
||||||
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
|
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
|
||||||
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
|
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
|
||||||
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
||||||
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
|
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
|
||||||
|
|
||||||
|
// Fetch cached Zonerama gallery from prefetch
|
||||||
|
const fetchCachedGallery = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGalleryLoading(true);
|
||||||
|
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||||
|
const origin = new URL(apiUrl).origin;
|
||||||
|
const url = `${origin}/cache/prefetch/zonerama_profile.json`;
|
||||||
|
const res = await fetch(url, { cache: 'no-cache' });
|
||||||
|
if (!res.ok) throw new Error('Failed to load gallery cache');
|
||||||
|
const data = await res.json();
|
||||||
|
const albums = Array.isArray(data?.albums) ? data.albums : [];
|
||||||
|
// Filter albums with photos
|
||||||
|
const validAlbums = albums.filter((a: any) => Array.isArray(a.photos) && a.photos.length > 0);
|
||||||
|
setCachedAlbums(validAlbums);
|
||||||
|
if (validAlbums.length === 0) {
|
||||||
|
toast({ title: 'Žádné alba nenalezena', description: 'Cache galerie je prázdná nebo neobsahuje fotografie.', status: 'info', duration: 4000 });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Načtení galerie selhalo', description: e?.message || 'Zkuste to prosím znovu.', status: 'error' });
|
||||||
|
} finally {
|
||||||
|
setGalleryLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Remove toast from dependencies to prevent infinite loops
|
// Remove toast from dependencies to prevent infinite loops
|
||||||
const fetchYouTubeVideos = useCallback(async () => {
|
const fetchYouTubeVideos = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -251,6 +287,12 @@ const ArticlesAdminPage = () => {
|
|||||||
}
|
}
|
||||||
}, [isYouTubeModalOpen, youtubeVideos.length, youtubeLoading, fetchYouTubeVideos]);
|
}, [isYouTubeModalOpen, youtubeVideos.length, youtubeLoading, fetchYouTubeVideos]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isGalleryPickerOpen && cachedAlbums.length === 0 && !galleryLoading) {
|
||||||
|
fetchCachedGallery();
|
||||||
|
}
|
||||||
|
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
|
||||||
|
|
||||||
const filteredYoutubeVideos = useMemo(() => {
|
const filteredYoutubeVideos = useMemo(() => {
|
||||||
const q = youtubeSearch.trim().toLowerCase();
|
const q = youtubeSearch.trim().toLowerCase();
|
||||||
if (!q) return youtubeVideos;
|
if (!q) return youtubeVideos;
|
||||||
@@ -943,7 +985,12 @@ const ArticlesAdminPage = () => {
|
|||||||
{!isLoading && articles.map((a) => (
|
{!isLoading && articles.map((a) => (
|
||||||
<Tr key={a.id}>
|
<Tr key={a.id}>
|
||||||
<Td>
|
<Td>
|
||||||
<Image src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'} alt={a.title} boxSize="48px" objectFit="cover" />
|
<ThumbnailPreview
|
||||||
|
src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'}
|
||||||
|
alt={a.title}
|
||||||
|
size="48px"
|
||||||
|
previewSize="350px"
|
||||||
|
/>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{a.title}</Td>
|
<Td>{a.title}</Td>
|
||||||
<Td>{a.published ? 'Ano' : 'Ne'}</Td>
|
<Td>{a.published ? 'Ano' : 'Ne'}</Td>
|
||||||
@@ -1156,11 +1203,12 @@ const ArticlesAdminPage = () => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}) : '';
|
}) : '';
|
||||||
|
|
||||||
// Parse match info from label
|
// Use match data directly
|
||||||
const parts = match.label.split('•');
|
const home = match.home || '';
|
||||||
const teams = parts[1]?.split(/\(|vs/)[0]?.trim() || '';
|
const away = match.away || '';
|
||||||
const score = teams.match(/\d+:\d+/)?.[0] || 'vs';
|
const score = match.score || 'vs';
|
||||||
const hasScore = score !== 'vs';
|
const hasScore = score !== 'vs';
|
||||||
|
const teams = `${home} ${score} ${away}`.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -1275,6 +1323,7 @@ const ArticlesAdminPage = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Button size="sm" onClick={fetchAlbumByLink} isLoading={zLoading}>Načíst album</Button>
|
<Button size="sm" onClick={fetchAlbumByLink} isLoading={zLoading}>Načíst album</Button>
|
||||||
|
<Button size="sm" colorScheme="purple" onClick={onGalleryPickerOpen}>Vybrat z galerie</Button>
|
||||||
{zAlbumLink ? (
|
{zAlbumLink ? (
|
||||||
<Button size="sm" as="a" href={zAlbumLink} target="_blank" rel="noopener noreferrer" rightIcon={<FiExternalLink />}>Otevřít album</Button>
|
<Button size="sm" as="a" href={zAlbumLink} target="_blank" rel="noopener noreferrer" rightIcon={<FiExternalLink />}>Otevřít album</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1561,6 +1610,95 @@ const ArticlesAdminPage = () => {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Zonerama Gallery Picker Modal */}
|
||||||
|
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxH="90vh">
|
||||||
|
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody overflowY="auto">
|
||||||
|
<VStack align="stretch" spacing={4}>
|
||||||
|
{/* Loading State */}
|
||||||
|
{galleryLoading && (
|
||||||
|
<HStack spacing={2} justify="center" py={8}>
|
||||||
|
<Spinner size="lg" color="purple.500" />
|
||||||
|
<Text color="gray.600">Načítám alba z galerie...</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Albums Grid */}
|
||||||
|
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||||
|
<VStack align="stretch" spacing={6}>
|
||||||
|
{cachedAlbums.map((album) => (
|
||||||
|
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
|
||||||
|
<HStack justify="space-between" mb={3}>
|
||||||
|
<VStack align="start" spacing={0}>
|
||||||
|
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500">{album.date} • {album.photos.length} fotografií</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
|
||||||
|
{album.photos.map((photo) => (
|
||||||
|
<Box
|
||||||
|
key={photo.id}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
cursor="pointer"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
|
||||||
|
onClick={() => {
|
||||||
|
pickZoneramaImage({
|
||||||
|
id: photo.id,
|
||||||
|
album_id: album.id,
|
||||||
|
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
|
||||||
|
page_url: photo.page_url,
|
||||||
|
image_url: photo.image_1500,
|
||||||
|
title: album.title
|
||||||
|
});
|
||||||
|
onGalleryPickerClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AspectRatio ratio={1}>
|
||||||
|
<Image
|
||||||
|
src={photo.image_1500}
|
||||||
|
alt={photo.id}
|
||||||
|
objectFit="cover"
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!galleryLoading && cachedAlbums.length === 0 && (
|
||||||
|
<VStack py={8} spacing={3}>
|
||||||
|
<Icon as={FiSearch} boxSize={12} color="gray.400" />
|
||||||
|
<Text color="gray.600" textAlign="center">
|
||||||
|
Žádná alba nebyla nalezena v cache.
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||||
|
Zkontrolujte nastavení Zonerama nebo obnovte cache.
|
||||||
|
</Text>
|
||||||
|
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
|
||||||
|
Obnovit seznam
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" onClick={onGalleryPickerClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
getDuplicateFiles,
|
getDuplicateFiles,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
scanAndSyncFiles,
|
scanAndSyncFiles,
|
||||||
|
refreshFileTracking,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
} from '../../services/files';
|
} from '../../services/files';
|
||||||
@@ -72,10 +73,12 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
|
||||||
const [forceDelete, setForceDelete] = useState(false);
|
const [forceDelete, setForceDelete] = useState(false);
|
||||||
const [scanResult, setScanResult] = useState<any>(null);
|
const [scanResult, setScanResult] = useState<any>(null);
|
||||||
|
const [refreshResult, setRefreshResult] = useState<any>(null);
|
||||||
|
|
||||||
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
|
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
|
||||||
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
||||||
const { isOpen: isScanResultOpen, onOpen: onScanResultOpen, onClose: onScanResultClose } = useDisclosure();
|
const { isOpen: isScanResultOpen, onOpen: onScanResultOpen, onClose: onScanResultClose } = useDisclosure();
|
||||||
|
const { isOpen: isRefreshResultOpen, onOpen: onRefreshResultOpen, onClose: onRefreshResultClose } = useDisclosure();
|
||||||
|
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||||
@@ -145,6 +148,21 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh tracking mutation
|
||||||
|
const refreshTrackingMutation = useMutation({
|
||||||
|
mutationFn: refreshFileTracking,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setRefreshResult(data);
|
||||||
|
onRefreshResultOpen();
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: 'Chyba při aktualizaci sledování', status: 'error' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleDelete = (file: FileInfo) => {
|
const handleDelete = (file: FileInfo) => {
|
||||||
setDeleteTarget(file);
|
setDeleteTarget(file);
|
||||||
setForceDelete(false);
|
setForceDelete(false);
|
||||||
@@ -266,15 +284,27 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
<VStack align="stretch" spacing={6}>
|
<VStack align="stretch" spacing={6}>
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Heading size="lg">Správa souborů</Heading>
|
<Heading size="lg">Správa souborů</Heading>
|
||||||
<Button
|
<HStack spacing={2}>
|
||||||
leftIcon={<FiRefreshCw />}
|
<Button
|
||||||
onClick={() => scanMutation.mutate()}
|
leftIcon={<FiRefreshCw />}
|
||||||
isLoading={scanMutation.isPending}
|
onClick={() => refreshTrackingMutation.mutate(undefined)}
|
||||||
colorScheme="blue"
|
isLoading={refreshTrackingMutation.isPending}
|
||||||
size="sm"
|
colorScheme="green"
|
||||||
>
|
size="sm"
|
||||||
Skenovat soubory
|
variant="outline"
|
||||||
</Button>
|
>
|
||||||
|
Aktualizovat sledování
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftIcon={<FiRefreshCw />}
|
||||||
|
onClick={() => scanMutation.mutate()}
|
||||||
|
isLoading={scanMutation.isPending}
|
||||||
|
colorScheme="blue"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Skenovat soubory
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Tabs colorScheme="blue" variant="enclosed">
|
<Tabs colorScheme="blue" variant="enclosed">
|
||||||
@@ -657,6 +687,81 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Refresh Tracking Result Modal */}
|
||||||
|
<Modal isOpen={isRefreshResultOpen} onClose={onRefreshResultClose} size="lg">
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Výsledky aktualizace sledování</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
{refreshResult && (
|
||||||
|
<VStack align="stretch" spacing={4}>
|
||||||
|
<Alert status="success">
|
||||||
|
<AlertIcon />
|
||||||
|
<Box>
|
||||||
|
<AlertTitle>Sledování aktualizováno!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{refreshResult.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" mb={2}>Statistiky:</Text>
|
||||||
|
|
||||||
|
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||||
|
<Text fontWeight="medium">Články:</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
{refreshResult.stats.articles_scanned}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||||
|
<Text fontWeight="medium">Aktivity:</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
{refreshResult.stats.events_scanned}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||||
|
<Text fontWeight="medium">Hráči:</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
{refreshResult.stats.players_scanned}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||||
|
<Text fontWeight="medium">Sponzoři:</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
{refreshResult.stats.sponsors_scanned}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||||
|
<Text fontWeight="medium">Kontakty:</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
{refreshResult.stats.contacts_scanned}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||||
|
<Text fontWeight="medium">Týmy:</Text>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
{refreshResult.stats.teams_scanned}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button colorScheme="blue" onClick={onRefreshResultClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -482,6 +482,10 @@ const MatchesAdminPage = () => {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [startX, setStartX] = useState(0);
|
const [startX, setStartX] = useState(0);
|
||||||
const [scrollLeft, setScrollLeft] = useState(0);
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
|
const [lastX, setLastX] = useState(0);
|
||||||
|
const [lastTime, setLastTime] = useState(0);
|
||||||
|
const velocityRef = useRef(0);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Color modes for past/future matches
|
// Color modes for past/future matches
|
||||||
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
||||||
@@ -499,11 +503,20 @@ const MatchesAdminPage = () => {
|
|||||||
// Drag-to-scroll handlers
|
// Drag-to-scroll handlers
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
|
// Cancel any ongoing momentum animation
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
||||||
setScrollLeft(scrollRef.current.scrollLeft);
|
setScrollLeft(scrollRef.current.scrollLeft);
|
||||||
|
setLastX(e.pageX);
|
||||||
|
setLastTime(Date.now());
|
||||||
|
velocityRef.current = 0;
|
||||||
scrollRef.current.style.cursor = 'grabbing';
|
scrollRef.current.style.cursor = 'grabbing';
|
||||||
scrollRef.current.style.userSelect = 'none';
|
scrollRef.current.style.userSelect = 'none';
|
||||||
|
scrollRef.current.style.scrollBehavior = 'auto'; // Disable smooth scroll during drag
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
@@ -519,6 +532,24 @@ const MatchesAdminPage = () => {
|
|||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.style.cursor = 'grab';
|
scrollRef.current.style.cursor = 'grab';
|
||||||
scrollRef.current.style.userSelect = 'auto';
|
scrollRef.current.style.userSelect = 'auto';
|
||||||
|
scrollRef.current.style.scrollBehavior = 'smooth';
|
||||||
|
|
||||||
|
// Apply momentum scrolling
|
||||||
|
const velocity = velocityRef.current;
|
||||||
|
if (Math.abs(velocity) > 0.5) {
|
||||||
|
const applyMomentum = () => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
velocityRef.current *= 0.95; // Deceleration factor
|
||||||
|
scrollRef.current.scrollLeft -= velocityRef.current;
|
||||||
|
|
||||||
|
if (Math.abs(velocityRef.current) > 0.5) {
|
||||||
|
animationRef.current = requestAnimationFrame(applyMomentum);
|
||||||
|
} else {
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animationRef.current = requestAnimationFrame(applyMomentum);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -526,8 +557,77 @@ const MatchesAdminPage = () => {
|
|||||||
if (!isDragging || !scrollRef.current) return;
|
if (!isDragging || !scrollRef.current) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const x = e.pageX - scrollRef.current.offsetLeft;
|
const x = e.pageX - scrollRef.current.offsetLeft;
|
||||||
const walk = (x - startX) * 2; // Scroll speed multiplier
|
const walk = (x - startX) * 1.5; // Scroll speed multiplier (reduced for smoother feel)
|
||||||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||||||
|
|
||||||
|
// Calculate velocity for momentum
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDelta = now - lastTime;
|
||||||
|
if (timeDelta > 0) {
|
||||||
|
const currentX = e.pageX;
|
||||||
|
const distance = currentX - lastX;
|
||||||
|
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
|
||||||
|
setLastX(currentX);
|
||||||
|
setLastTime(now);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch handlers for mobile
|
||||||
|
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartX(touch.pageX - scrollRef.current.offsetLeft);
|
||||||
|
setScrollLeft(scrollRef.current.scrollLeft);
|
||||||
|
setLastX(touch.pageX);
|
||||||
|
setLastTime(Date.now());
|
||||||
|
velocityRef.current = 0;
|
||||||
|
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDragging || !scrollRef.current) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const x = touch.pageX - scrollRef.current.offsetLeft;
|
||||||
|
const walk = (x - startX) * 1.5;
|
||||||
|
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDelta = now - lastTime;
|
||||||
|
if (timeDelta > 0) {
|
||||||
|
const currentX = touch.pageX;
|
||||||
|
const distance = currentX - lastX;
|
||||||
|
velocityRef.current = distance / timeDelta * 16;
|
||||||
|
setLastX(currentX);
|
||||||
|
setLastTime(now);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.style.scrollBehavior = 'smooth';
|
||||||
|
|
||||||
|
const velocity = velocityRef.current;
|
||||||
|
if (Math.abs(velocity) > 0.5) {
|
||||||
|
const applyMomentum = () => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
velocityRef.current *= 0.95;
|
||||||
|
scrollRef.current.scrollLeft -= velocityRef.current;
|
||||||
|
|
||||||
|
if (Math.abs(velocityRef.current) > 0.5) {
|
||||||
|
animationRef.current = requestAnimationFrame(applyMomentum);
|
||||||
|
} else {
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animationRef.current = requestAnimationFrame(applyMomentum);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utility to check if match is in the past
|
// Utility to check if match is in the past
|
||||||
@@ -551,7 +651,13 @@ const MatchesAdminPage = () => {
|
|||||||
updateScrollShadow();
|
updateScrollShadow();
|
||||||
const onResize = () => updateScrollShadow();
|
const onResize = () => updateScrollShadow();
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
return () => window.removeEventListener('resize', onResize);
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
// Cleanup momentum animation on unmount
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const headerBg = useColorModeValue('brand.primary', 'gray.700');
|
const headerBg = useColorModeValue('brand.primary', 'gray.700');
|
||||||
@@ -656,8 +762,8 @@ const MatchesAdminPage = () => {
|
|||||||
</WrapItem>
|
</WrapItem>
|
||||||
</Wrap>
|
</Wrap>
|
||||||
{showScrollHint && (
|
{showScrollHint && (
|
||||||
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2}>
|
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2} display="flex" alignItems="center" gap={1}>
|
||||||
💡 Tip: Tabulku můžete posouvat tažením myší nebo touchem →
|
💡 Tip: Tabulku můžete plynule posouvat tažením myší nebo prstem →
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Box
|
<Box
|
||||||
@@ -676,23 +782,33 @@ const MatchesAdminPage = () => {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
updateScrollShadow();
|
updateScrollShadow();
|
||||||
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
scrollBehavior: 'smooth',
|
||||||
'th, td': { whiteSpace: 'nowrap' },
|
'th, td': { whiteSpace: 'nowrap' },
|
||||||
'::-webkit-scrollbar': { height: '12px' },
|
'::-webkit-scrollbar': { height: '14px' },
|
||||||
'::-webkit-scrollbar-thumb': {
|
'::-webkit-scrollbar-thumb': {
|
||||||
background: '#3182ce',
|
background: '#3182ce',
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
'&:hover': { background: '#2c5aa0' }
|
border: '3px solid transparent',
|
||||||
|
backgroundClip: 'content-box',
|
||||||
|
transition: 'background 0.2s ease',
|
||||||
|
'&:hover': { background: '#2c5aa0', backgroundClip: 'content-box' },
|
||||||
|
'&:active': { background: '#2a4e8a', backgroundClip: 'content-box' }
|
||||||
},
|
},
|
||||||
'::-webkit-scrollbar-track': {
|
'::-webkit-scrollbar-track': {
|
||||||
background: '#e2e8f0',
|
background: useColorModeValue('#f7fafc', '#2d3748'),
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
margin: '0 4px'
|
margin: '0 8px',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: useColorModeValue('#e2e8f0', '#4a5568')
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import AdminLayout from '../../layouts/AdminLayout';
|
|||||||
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
|
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
|
||||||
import { uploadFile } from '../../services/articles';
|
import { uploadFile } from '../../services/articles';
|
||||||
import { translateNationality } from '../../utils/nationality';
|
import { translateNationality } from '../../utils/nationality';
|
||||||
|
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
|
|
||||||
type Editing = Partial<Player> & { id?: number };
|
type Editing = Partial<Player> & { id?: number };
|
||||||
|
|
||||||
@@ -337,7 +338,13 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
{!isLoading && (data || []).map((p) => (
|
{!isLoading && (data || []).map((p) => (
|
||||||
<Tr key={p.id}>
|
<Tr key={p.id}>
|
||||||
<Td>
|
<Td>
|
||||||
<Image src={normalizeImageUrl(p.image_url)} alt={p.first_name} boxSize="48px" objectFit="cover" borderRadius="md" />
|
<ThumbnailPreview
|
||||||
|
src={normalizeImageUrl(p.image_url)}
|
||||||
|
alt={`${p.first_name} ${p.last_name}`}
|
||||||
|
size="48px"
|
||||||
|
previewSize="300px"
|
||||||
|
borderRadius="md"
|
||||||
|
/>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{p.first_name} {p.last_name}</Td>
|
<Td>{p.first_name} {p.last_name}</Td>
|
||||||
<Td>{p.position || '-'}</Td>
|
<Td>{p.position || '-'}</Td>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -808,27 +808,28 @@ html {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 8px 2px 12px 2px;
|
padding: 8px 2px 16px 2px;
|
||||||
cursor: grab;
|
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.matches-slider .matches-track::-webkit-scrollbar {
|
.matches-slider .matches-track::-webkit-scrollbar {
|
||||||
height: 8px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
.matches-slider .matches-track::-webkit-scrollbar-track {
|
.matches-slider .matches-track::-webkit-scrollbar-track {
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
margin: 0 12px;
|
||||||
}
|
}
|
||||||
.matches-slider .matches-track::-webkit-scrollbar-thumb {
|
.matches-slider .matches-track::-webkit-scrollbar-thumb {
|
||||||
background: var(--light-gray);
|
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 80%, var(--secondary) 20%));
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
transition: background 0.2s;
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid var(--bg-soft);
|
||||||
}
|
}
|
||||||
.matches-slider .matches-track::-webkit-scrollbar-thumb:hover {
|
.matches-slider .matches-track::-webkit-scrollbar-thumb:hover {
|
||||||
background: color-mix(in srgb, var(--primary) 40%, var(--light-gray));
|
background: linear-gradient(90deg, color-mix(in srgb, var(--primary) 120%, #000), var(--primary));
|
||||||
|
transform: scaleY(1.1);
|
||||||
}
|
}
|
||||||
.matches-slider .matches-track:active { cursor: grabbing; }
|
|
||||||
.match-card {
|
.match-card {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 340px;
|
min-width: 340px;
|
||||||
|
|||||||
@@ -100,6 +100,25 @@ export const scanAndSyncFiles = async (): Promise<{
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const refreshFileTracking = async (entityType?: string): Promise<{
|
||||||
|
message: string;
|
||||||
|
stats: {
|
||||||
|
articles_scanned: number;
|
||||||
|
events_scanned: number;
|
||||||
|
players_scanned: number;
|
||||||
|
sponsors_scanned: number;
|
||||||
|
contacts_scanned: number;
|
||||||
|
teams_scanned: number;
|
||||||
|
settings_scanned: number;
|
||||||
|
};
|
||||||
|
}> => {
|
||||||
|
const response = await axios.post(`${API_URL}/admin/files/refresh-tracking`, {}, {
|
||||||
|
params: entityType ? { entity_type: entityType } : {},
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const formatFileSize = (bytes: number): string => {
|
export const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function parseGoogleMapsUrl(url: string): MapCoordinates | null {
|
|||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
|
|
||||||
// Check if it's a Google Maps domain
|
// Check if it's a Google Maps domain
|
||||||
if (!urlObj.hostname.includes('google.com')) {
|
if (!urlObj.hostname.includes('google.com') && !urlObj.hostname.includes('google.cz')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"fotbal-club/internal/models"
|
"fotbal-club/internal/models"
|
||||||
|
"fotbal-club/internal/services"
|
||||||
"fotbal-club/pkg/logger"
|
"fotbal-club/pkg/logger"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -366,13 +367,37 @@ func calculateFileMD5(filePath string) (string, error) {
|
|||||||
func detectMimeType(filePath string) string {
|
func detectMimeType(filePath string) string {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
mimeTypes := map[string]string{
|
mimeTypes := map[string]string{
|
||||||
|
// Images
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
".pdf": "application/pdf",
|
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
// Documents
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".doc": "application/msword",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".ppt": "application/vnd.ms-powerpoint",
|
||||||
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".csv": "text/csv",
|
||||||
|
// Archives
|
||||||
|
".zip": "application/zip",
|
||||||
|
".rar": "application/x-rar-compressed",
|
||||||
|
".7z": "application/x-7z-compressed",
|
||||||
|
".tar": "application/x-tar",
|
||||||
|
".gz": "application/gzip",
|
||||||
|
// Media
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".avi": "video/x-msvideo",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
}
|
}
|
||||||
|
|
||||||
if mime, ok := mimeTypes[ext]; ok {
|
if mime, ok := mimeTypes[ext]; ok {
|
||||||
@@ -423,3 +448,107 @@ func getEntityInfo(db *gorm.DB, entityType string, entityID uint) map[string]int
|
|||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshFileTracking re-scans all entities and updates file usage tracking
|
||||||
|
func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
|
||||||
|
entityType := c.Query("entity_type") // Optional: "article", "event", "player", etc.
|
||||||
|
|
||||||
|
stats := map[string]int{
|
||||||
|
"articles_scanned": 0,
|
||||||
|
"events_scanned": 0,
|
||||||
|
"players_scanned": 0,
|
||||||
|
"sponsors_scanned": 0,
|
||||||
|
"contacts_scanned": 0,
|
||||||
|
"teams_scanned": 0,
|
||||||
|
"settings_scanned": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
fileTracker := services.NewFileTracker(fc.DB)
|
||||||
|
|
||||||
|
// Refresh articles
|
||||||
|
if entityType == "" || entityType == "article" {
|
||||||
|
var articles []models.Article
|
||||||
|
if err := fc.DB.Find(&articles).Error; err == nil {
|
||||||
|
for _, article := range articles {
|
||||||
|
fileTracker.TrackArticleFiles(&article)
|
||||||
|
stats["articles_scanned"]++
|
||||||
|
}
|
||||||
|
logger.Info("Refreshed file tracking for %d articles", len(articles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh events
|
||||||
|
if entityType == "" || entityType == "event" {
|
||||||
|
var events []models.Event
|
||||||
|
if err := fc.DB.Preload("Attachments").Find(&events).Error; err == nil {
|
||||||
|
for _, event := range events {
|
||||||
|
fileTracker.TrackEventFiles(&event)
|
||||||
|
stats["events_scanned"]++
|
||||||
|
}
|
||||||
|
logger.Info("Refreshed file tracking for %d events", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh players
|
||||||
|
if entityType == "" || entityType == "player" {
|
||||||
|
var players []models.Player
|
||||||
|
if err := fc.DB.Find(&players).Error; err == nil {
|
||||||
|
for _, player := range players {
|
||||||
|
fileTracker.TrackPlayerFiles(&player)
|
||||||
|
stats["players_scanned"]++
|
||||||
|
}
|
||||||
|
logger.Info("Refreshed file tracking for %d players", len(players))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh sponsors
|
||||||
|
if entityType == "" || entityType == "sponsor" {
|
||||||
|
var sponsors []models.Sponsor
|
||||||
|
if err := fc.DB.Find(&sponsors).Error; err == nil {
|
||||||
|
for _, sponsor := range sponsors {
|
||||||
|
fileTracker.TrackSponsorFiles(&sponsor)
|
||||||
|
stats["sponsors_scanned"]++
|
||||||
|
}
|
||||||
|
logger.Info("Refreshed file tracking for %d sponsors", len(sponsors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh contacts
|
||||||
|
if entityType == "" || entityType == "contact" {
|
||||||
|
var contacts []models.Contact
|
||||||
|
if err := fc.DB.Find(&contacts).Error; err == nil {
|
||||||
|
for _, contact := range contacts {
|
||||||
|
fileTracker.TrackContactFiles(&contact)
|
||||||
|
stats["contacts_scanned"]++
|
||||||
|
}
|
||||||
|
logger.Info("Refreshed file tracking for %d contacts", len(contacts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh teams
|
||||||
|
if entityType == "" || entityType == "team" {
|
||||||
|
var teams []models.Team
|
||||||
|
if err := fc.DB.Find(&teams).Error; err == nil {
|
||||||
|
for _, team := range teams {
|
||||||
|
fileTracker.TrackTeamFiles(&team)
|
||||||
|
stats["teams_scanned"]++
|
||||||
|
}
|
||||||
|
logger.Info("Refreshed file tracking for %d teams", len(teams))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh settings
|
||||||
|
if entityType == "" || entityType == "settings" {
|
||||||
|
var settings models.Settings
|
||||||
|
if err := fc.DB.First(&settings).Error; err == nil {
|
||||||
|
fileTracker.TrackSettingsFiles(&settings)
|
||||||
|
stats["settings_scanned"]++
|
||||||
|
logger.Info("Refreshed file tracking for settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "File tracking refreshed successfully",
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,15 +108,28 @@ func (nc *NavigationController) CreateNavigationItem(c *gin.Context) {
|
|||||||
// If no display order is set, put it at the end
|
// If no display order is set, put it at the end
|
||||||
if item.DisplayOrder == 0 {
|
if item.DisplayOrder == 0 {
|
||||||
var maxOrder int
|
var maxOrder int
|
||||||
nc.DB.Model(&models.NavigationItem{}).
|
query := nc.DB.Model(&models.NavigationItem{})
|
||||||
Where("parent_id IS NULL").
|
|
||||||
Select("COALESCE(MAX(display_order), -1) + 1").
|
// Calculate max order for items at the same level (same parent) and same admin status
|
||||||
Scan(&maxOrder)
|
if item.ParentID == nil {
|
||||||
|
query = query.Where("parent_id IS NULL")
|
||||||
|
} else {
|
||||||
|
query = query.Where("parent_id = ?", *item.ParentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also consider requires_admin to keep frontend and admin items separate
|
||||||
|
query = query.Where("requires_admin = ?", item.RequiresAdmin)
|
||||||
|
|
||||||
|
query.Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxOrder)
|
||||||
item.DisplayOrder = maxOrder
|
item.DisplayOrder = maxOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := nc.DB.Create(&item).Error; err != nil {
|
if err := nc.DB.Create(&item).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create navigation item"})
|
// Log the actual error for debugging
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Failed to create navigation item",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +182,10 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
|||||||
item.RequiresAdmin = updates.RequiresAdmin
|
item.RequiresAdmin = updates.RequiresAdmin
|
||||||
|
|
||||||
if err := nc.DB.Save(&item).Error; err != nil {
|
if err := nc.DB.Save(&item).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update navigation item"})
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Failed to update navigation item",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
files.GET("/:id/usages", filesController.GetFileUsages)
|
files.GET("/:id/usages", filesController.GetFileUsages)
|
||||||
files.DELETE("/:id", filesController.DeleteFile)
|
files.DELETE("/:id", filesController.DeleteFile)
|
||||||
files.POST("/scan", filesController.ScanAndSyncFiles)
|
files.POST("/scan", filesController.ScanAndSyncFiles)
|
||||||
|
files.POST("/refresh-tracking", filesController.RefreshFileTracking)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation management (admin)
|
// Navigation management (admin)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fotbal-club/internal/models"
|
"fotbal-club/internal/models"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -120,19 +123,35 @@ func (ft *FileTracker) TrackArticleFiles(article *models.Article) error {
|
|||||||
|
|
||||||
// Track attachments if present
|
// Track attachments if present
|
||||||
if article.Attachments != "" {
|
if article.Attachments != "" {
|
||||||
// Attachments is a JSON array of URLs
|
// Attachments is a JSON array of URLs - parse properly
|
||||||
// For simplicity, we'll track each attachment URL separately
|
var attachmentURLs []string
|
||||||
// You might want to parse the JSON properly in production
|
if err := json.Unmarshal([]byte(article.Attachments), &attachmentURLs); err == nil {
|
||||||
attachments := strings.Split(article.Attachments, ",")
|
// Successfully parsed as JSON array
|
||||||
for i, attachment := range attachments {
|
for i, attachmentURL := range attachmentURLs {
|
||||||
attachment = strings.Trim(attachment, `[]" `)
|
if attachmentURL != "" {
|
||||||
if attachment != "" {
|
// Extract filename from URL for better field naming
|
||||||
fieldName := "attachments"
|
filename := filepath.Base(attachmentURL)
|
||||||
if i > 0 {
|
fieldName := "attachment_" + filename
|
||||||
// If multiple attachments, differentiate them
|
// Ensure unique field names if same filename appears multiple times
|
||||||
fieldName = "attachments"
|
if _, exists := fieldURLMap[fieldName]; exists {
|
||||||
|
fieldName = "attachment_" + filename + "_" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
fieldURLMap[fieldName] = attachmentURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to simple comma-separated parsing
|
||||||
|
attachments := strings.Split(article.Attachments, ",")
|
||||||
|
for i, attachment := range attachments {
|
||||||
|
attachment = strings.Trim(attachment, `[]" `)
|
||||||
|
if attachment != "" {
|
||||||
|
filename := filepath.Base(attachment)
|
||||||
|
fieldName := "attachment_" + filename
|
||||||
|
if _, exists := fieldURLMap[fieldName]; exists {
|
||||||
|
fieldName = "attachment_" + filename + "_" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
fieldURLMap[fieldName] = attachment
|
||||||
}
|
}
|
||||||
fieldURLMap[fieldName] = attachment
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +181,39 @@ func (ft *FileTracker) TrackEventFiles(event *models.Event) error {
|
|||||||
"image_url": event.ImageURL,
|
"image_url": event.ImageURL,
|
||||||
"file_url": event.FileURL,
|
"file_url": event.FileURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track each attachment separately
|
||||||
|
for i, attachment := range event.Attachments {
|
||||||
|
if attachment.URL != "" {
|
||||||
|
// Generate field name from attachment name or filename
|
||||||
|
fieldName := ""
|
||||||
|
if attachment.Name != "" {
|
||||||
|
// Use attachment name if available
|
||||||
|
fieldName = "attachment_" + strings.ReplaceAll(attachment.Name, " ", "_")
|
||||||
|
} else {
|
||||||
|
// Fall back to filename from URL
|
||||||
|
filename := filepath.Base(attachment.URL)
|
||||||
|
fieldName = "attachment_" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure unique field names if duplicates exist
|
||||||
|
originalFieldName := fieldName
|
||||||
|
for counter := 0; ; counter++ {
|
||||||
|
if _, exists := fieldURLMap[fieldName]; !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Add counter suffix if field name already exists
|
||||||
|
fieldName = originalFieldName + "_" + strconv.Itoa(counter)
|
||||||
|
if counter > 999 { // Prevent infinite loop
|
||||||
|
fieldName = originalFieldName + "_" + strconv.Itoa(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldURLMap[fieldName] = attachment.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ft.UpdateFileUsages("event", event.ID, fieldURLMap)
|
return ft.UpdateFileUsages("event", event.ID, fieldURLMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 340 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
Reference in New Issue
Block a user