mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #65,5
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
# Blog Match Link Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
When creating a new blog article with a match link in the admin panel, the match link was not being saved to the database. The article was created successfully, but the association with the FACR match was lost.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
The match linking logic was placed in the React Query mutation's `onSuccess` callback. When the page re-rendered after article creation (especially when invalidating queries), a React error #310 was occurring, which interrupted the `onSuccess` callback before the match linking API call could complete.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Moved the match linking logic from the mutation's `onSuccess` callback directly into the `onSubmit` function. This ensures the match linking happens synchronously after article creation, before the modal closes and queries are invalidated.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
#### 1. Match Linking in `onSubmit` Function
|
||||||
|
**File**: `frontend/src/pages/admin/ArticlesAdminPage.tsx`
|
||||||
|
|
||||||
|
- For **new articles**: After `createArticle()` completes, immediately call `putArticleMatchLink()` with the new article ID
|
||||||
|
- For **existing articles**: After `updateArticle()` completes, call `putArticleMatchLink()` to update or create the link
|
||||||
|
- All match linking now happens within the same try-catch block as article creation/update
|
||||||
|
- Modal only closes after all operations complete successfully
|
||||||
|
|
||||||
|
#### 2. Simplified Mutation Callbacks
|
||||||
|
- `createMut.onSuccess`: Only handles query invalidation and state cleanup
|
||||||
|
- `updateMut.onSuccess`: Only handles query invalidation
|
||||||
|
- Removed duplicate match linking code from callbacks
|
||||||
|
- Success toasts moved to `onSubmit` after all operations complete
|
||||||
|
|
||||||
|
#### 3. Enhanced Error Handling
|
||||||
|
- Added try-catch blocks around match linking API calls
|
||||||
|
- Separate error messages for article creation vs. match linking failures
|
||||||
|
- If article creation succeeds but match linking fails, user gets a warning toast with instructions
|
||||||
|
- All errors logged to console for debugging
|
||||||
|
|
||||||
|
#### 4. Added Debug Logging
|
||||||
|
- Log match link state before submission
|
||||||
|
- Log article creation success
|
||||||
|
- Log match linking attempts and results
|
||||||
|
- Helps diagnose issues in production
|
||||||
|
|
||||||
|
#### 5. Fixed `MatchLinkBadge` Loading State
|
||||||
|
- Added loading state check to prevent React errors when refetching
|
||||||
|
- Shows "Načítání..." badge while match link data is loading
|
||||||
|
|
||||||
|
## Testing Instructions
|
||||||
|
|
||||||
|
### Test 1: Create New Article with Match Link
|
||||||
|
1. Go to Admin → Články
|
||||||
|
2. Click "Nový článek"
|
||||||
|
3. Fill in required fields (Název, Kategorie)
|
||||||
|
4. Switch to "Základní" tab
|
||||||
|
5. Select a match from the picker
|
||||||
|
6. Click "Uložit"
|
||||||
|
7. **Expected**: Toast shows "Článek vytvořen a propojen se zápasem"
|
||||||
|
8. **Verify**: Article list shows match badge with match details
|
||||||
|
|
||||||
|
### Test 2: Create Article Without Match Link
|
||||||
|
1. Create article without selecting a match
|
||||||
|
2. **Expected**: Toast shows "Článek byl úspěšně vytvořen"
|
||||||
|
3. **Verify**: Article list shows "Nepropojeno" badge
|
||||||
|
|
||||||
|
### Test 3: Update Existing Article Match Link
|
||||||
|
1. Open existing article for editing
|
||||||
|
2. Select a different match
|
||||||
|
3. Click "Uložit"
|
||||||
|
4. **Expected**: Toast shows "Článek aktualizován a propojen se zápasem"
|
||||||
|
5. **Verify**: Badge updates to show new match
|
||||||
|
|
||||||
|
### Test 4: Match Linking Failure (Backend Error)
|
||||||
|
1. Simulate backend error (e.g., stop backend)
|
||||||
|
2. Try to create article with match link
|
||||||
|
3. **Expected**: Toast shows "Článek vytvořen, ale propojení se zápasem selhalo"
|
||||||
|
4. **Verify**: Article is created but without match link
|
||||||
|
|
||||||
|
## API Endpoints Used
|
||||||
|
- `POST /api/v1/articles` - Create article
|
||||||
|
- `PUT /api/v1/articles/:id` - Update article
|
||||||
|
- `POST /api/v1/articles/:id/match-link` - Create/update match link
|
||||||
|
- `GET /api/v1/articles/:id/match-link` - Get match link (for badge display)
|
||||||
|
- `DELETE /api/v1/articles/:id/match-link` - Delete match link
|
||||||
|
|
||||||
|
## Database Tables
|
||||||
|
- `articles` - Main article data
|
||||||
|
- `article_match_links` - Junction table linking articles to FACR match IDs
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
- `tempMatchLink` - Stores selected match ID for new articles
|
||||||
|
- `matchIdInput` - Stores selected match ID (UI input)
|
||||||
|
- `linkedMatchId` - Stores confirmed linked match ID after successful save
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
- Consider adding optimistic updates to show match link immediately
|
||||||
|
- Add bulk match linking for multiple articles
|
||||||
|
- Show match preview in article form before saving
|
||||||
|
- Add match link history/audit log
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Blog Match Link Fix - Verification Checklist
|
||||||
|
|
||||||
|
## Issue Summary
|
||||||
|
**Problem**: When creating a blog article and selecting a match from the "Propojit se zápasem" section, the match ID was not being saved to the database.
|
||||||
|
|
||||||
|
**User Evidence**: Console log showed:
|
||||||
|
```javascript
|
||||||
|
Saving article with payload: {
|
||||||
|
"title": "U17: Rýmařov...",
|
||||||
|
"category_name": "KALMAN TRADE Krajský přebor mladší dorost",
|
||||||
|
// ... other fields ...
|
||||||
|
// ❌ NO match_id field in payload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix Applied
|
||||||
|
|
||||||
|
### Technical Changes
|
||||||
|
1. **Moved match linking from async callback to synchronous flow**
|
||||||
|
- Previous: Match linking happened in `createMut.onSuccess` callback (after article creation)
|
||||||
|
- New: Match linking happens in `onSubmit` function (immediately after article creation completes)
|
||||||
|
- Benefit: Prevents race conditions and React error interruptions
|
||||||
|
|
||||||
|
2. **Added comprehensive error handling**
|
||||||
|
- Separate try-catch blocks for article save and match linking
|
||||||
|
- Clear user feedback for each operation
|
||||||
|
- Logging at each step for debugging
|
||||||
|
|
||||||
|
3. **Fixed React error #310**
|
||||||
|
- Added loading state to `MatchLinkBadge` component
|
||||||
|
- Prevents rendering errors during query refetch
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
### 1. Check Console Logs
|
||||||
|
After applying the fix, when creating an article with a match link, you should see:
|
||||||
|
```
|
||||||
|
Match link state before submit: {
|
||||||
|
tempMatchLink: "12345",
|
||||||
|
matchIdInput: "12345",
|
||||||
|
linkedMatchId: "",
|
||||||
|
isNewArticle: true
|
||||||
|
}
|
||||||
|
Linking new article 42 with match 12345
|
||||||
|
Match link created for new article
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Check Toast Messages
|
||||||
|
- ✅ Success: "Článek vytvořen a propojen se zápasem" (with Match ID)
|
||||||
|
- ⚠️ Partial Success: "Článek vytvořen, ale propojení se zápasem selhalo"
|
||||||
|
|
||||||
|
### 3. Check Database
|
||||||
|
Query to verify match link was saved:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM article_match_links WHERE article_id = [NEW_ARTICLE_ID];
|
||||||
|
```
|
||||||
|
Should return:
|
||||||
|
- `article_id`: The ID of the newly created article
|
||||||
|
- `external_match_id`: The FACR match ID (e.g., "12345")
|
||||||
|
- `title`: The article title
|
||||||
|
|
||||||
|
### 4. Check Article List UI
|
||||||
|
- Badge should show: "Zápas: [Home Team] [Score] [Away Team]" in green (if match has score) or yellow (if no score yet)
|
||||||
|
- Should NOT show: "Nepropojeno" in gray
|
||||||
|
|
||||||
|
### 5. Check API Response
|
||||||
|
The article creation response should include match_link:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "U17: Rýmařov...",
|
||||||
|
"match_link": {
|
||||||
|
"article_id": 42,
|
||||||
|
"external_match_id": "12345",
|
||||||
|
"title": "U17: Rýmařov..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### Scenario A: New Article with Match
|
||||||
|
1. Open Admin → Články → Nový článek
|
||||||
|
2. Fill in title: "Test článek"
|
||||||
|
3. Select category: "KALMAN TRADE Krajský přebor mladší dorost"
|
||||||
|
4. Click on a match in the match picker
|
||||||
|
5. Click "Uložit"
|
||||||
|
6. **Expected**: Green toast with match ID
|
||||||
|
7. **Verify**: List shows match badge with team names
|
||||||
|
|
||||||
|
### Scenario B: New Article without Match
|
||||||
|
1. Create article without selecting match
|
||||||
|
2. **Expected**: Standard success toast
|
||||||
|
3. **Verify**: List shows "Nepropojeno" badge
|
||||||
|
|
||||||
|
### Scenario C: Edit Existing Article, Add Match
|
||||||
|
1. Open existing article
|
||||||
|
2. Select a match
|
||||||
|
3. Click "Uložit"
|
||||||
|
4. **Expected**: Success toast with match info
|
||||||
|
5. **Verify**: Badge updates immediately
|
||||||
|
|
||||||
|
### Scenario D: Edit Existing Article, Change Match
|
||||||
|
1. Open article that already has a match
|
||||||
|
2. Select different match
|
||||||
|
3. Click "Uložit"
|
||||||
|
4. **Expected**: Success toast
|
||||||
|
5. **Verify**: Badge shows new match
|
||||||
|
|
||||||
|
### Scenario E: Backend Error Handling
|
||||||
|
1. Stop backend server
|
||||||
|
2. Try to create article with match
|
||||||
|
3. **Expected**: Article created locally, warning toast about match linking failure
|
||||||
|
4. Restart backend
|
||||||
|
5. **Verify**: Can manually link match by editing article
|
||||||
|
|
||||||
|
## Code Changes Summary
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
1. `/frontend/src/pages/admin/ArticlesAdminPage.tsx`
|
||||||
|
- Lines 40-43: Added loading state to MatchLinkBadge
|
||||||
|
- Lines 655-673: Simplified createMut.onSuccess
|
||||||
|
- Lines 686-702: Simplified updateMut.onSuccess
|
||||||
|
- Lines 928-987: Refactored onSubmit with inline match linking
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
1. `/DOCS/BLOG_MATCH_LINK_FIX.md` - Detailed fix documentation
|
||||||
|
2. `/DOCS/BLOG_MATCH_LINK_VERIFICATION.md` - This file
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
If issues occur, revert the ArticlesAdminPage.tsx changes:
|
||||||
|
```bash
|
||||||
|
git checkout HEAD -- frontend/src/pages/admin/ArticlesAdminPage.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Impact
|
||||||
|
- No performance degradation
|
||||||
|
- Actually improved: One less async operation in mutation callback
|
||||||
|
- Better user experience: Immediate feedback on match linking status
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
- No new browser APIs used
|
||||||
|
- Compatible with all modern browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
- React Query handles all async state management
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
- Match linking requires article to be saved first (can't preview match before save)
|
||||||
|
- If backend is down, match link won't be saved (fails gracefully)
|
||||||
|
- Maximum 100 matches shown in picker (performance optimization)
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
- `DOCS/BLOG_CREATION_FIXED.md` - Previous blog creation fixes
|
||||||
|
- `DOCS/FACR_INTEGRATION.md` - FACR match data integration
|
||||||
|
- `DOCS/ADMIN_QUICK_REFERENCE.md` - Admin panel overview
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# PDF Preview and Poll Creation Fix
|
||||||
|
|
||||||
|
## Issues Fixed
|
||||||
|
|
||||||
|
### 1. PDF Preview Not Working (Blank Screen)
|
||||||
|
**Problem**: When trying to preview PDF files, the screen was blank due to Content Security Policy (CSP) restrictions blocking iframe embedding.
|
||||||
|
|
||||||
|
**Root Cause**: CSP header `frame-ancestors 'self'` prevented PDF files from being embedded in iframes.
|
||||||
|
|
||||||
|
**Solution**: Enhanced `FilePreview.tsx` component with multiple fallback options:
|
||||||
|
- Primary: Direct iframe embed (works if CSP allows)
|
||||||
|
- Fallback buttons:
|
||||||
|
- Open in new window
|
||||||
|
- View with Mozilla PDF.js
|
||||||
|
- View via Google Docs Viewer
|
||||||
|
- Download PDF
|
||||||
|
|
||||||
|
### 2. Poll Creation Requires Saved Article
|
||||||
|
**Problem**: Users couldn't create or link polls to articles until the article was saved first. The UI showed "Nejprve uložte článek" (Save article first).
|
||||||
|
|
||||||
|
**Root Cause**: `PollLinker` component requires an `articleId` which only exists after the article is saved.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Added "Save as draft and add polls" button
|
||||||
|
- Modified `onSubmit` function to support `keepOpen` option
|
||||||
|
- After saving, modal stays open and switches to Poll tab automatically
|
||||||
|
- Article is saved as draft (published=true by default, but can be unpublished)
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### `/frontend/src/components/common/FilePreview.tsx`
|
||||||
|
**Lines changed**: 124-189
|
||||||
|
|
||||||
|
**What changed**:
|
||||||
|
- Wrapped PDF iframe in a VStack with fallback options
|
||||||
|
- Added 4 alternative viewing methods
|
||||||
|
- Added helpful message when PDF doesn't display
|
||||||
|
- Added error handler to iframe
|
||||||
|
|
||||||
|
**Key improvements**:
|
||||||
|
```tsx
|
||||||
|
// Before: Simple iframe only
|
||||||
|
<iframe src={`${fullUrl}#view=FitH`} />
|
||||||
|
|
||||||
|
// After: Iframe + fallback buttons
|
||||||
|
<VStack>
|
||||||
|
<Box>
|
||||||
|
<iframe src={`${fullUrl}#view=FitH&toolbar=1`} />
|
||||||
|
</Box>
|
||||||
|
<HStack>
|
||||||
|
<Button href={fullUrl}>Open in new window</Button>
|
||||||
|
<Button href={pdfjs_url}>View with PDF.js</Button>
|
||||||
|
<Button href={google_viewer_url}>View via Google</Button>
|
||||||
|
<Button download>Download PDF</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/frontend/src/pages/admin/ArticlesAdminPage.tsx`
|
||||||
|
**Lines changed**:
|
||||||
|
- 852: Modified `onSubmit` function signature
|
||||||
|
- 994-997: Added conditional modal closing
|
||||||
|
- 1840-1870: Enhanced Poll tab UI
|
||||||
|
|
||||||
|
**What changed**:
|
||||||
|
1. **Modified `onSubmit` function**:
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const onSubmit = async () => { ... }
|
||||||
|
|
||||||
|
// After
|
||||||
|
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
|
||||||
|
// ... save logic ...
|
||||||
|
if (!options.keepOpen) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Updated Poll tab**:
|
||||||
|
- Changed from "info" alert to "warning" alert
|
||||||
|
- Added "Save as draft and add polls" button
|
||||||
|
- Button calls `onSubmit({ keepOpen: true })`
|
||||||
|
- After save, switches to Poll tab: `setActiveTabIndex(5)`
|
||||||
|
|
||||||
|
## Testing Instructions
|
||||||
|
|
||||||
|
### Test 1: PDF Preview
|
||||||
|
1. **Upload PDF to article**:
|
||||||
|
- Go to Admin → Články → Edit article
|
||||||
|
- Go to "Média" tab
|
||||||
|
- Upload a PDF file in attachments
|
||||||
|
|
||||||
|
2. **Test preview**:
|
||||||
|
- Click "Náhled" button
|
||||||
|
- **Expected**: Modal opens with PDF preview
|
||||||
|
- **If PDF doesn't show**: Fallback buttons appear
|
||||||
|
- Click "Otevřít v novém okně" - PDF opens in new tab
|
||||||
|
- Click "Zobrazit pomocí PDF.js" - PDF opens in Mozilla viewer
|
||||||
|
- Click "Zobrazit přes Google" - PDF opens in Google Docs viewer
|
||||||
|
- Click "Stáhnout PDF" - PDF downloads
|
||||||
|
|
||||||
|
### Test 2: Poll Creation for New Article
|
||||||
|
1. **Create new article**:
|
||||||
|
- Go to Admin → Články → Nový článek
|
||||||
|
- Fill in title and category
|
||||||
|
- Go to "Anketa" tab
|
||||||
|
|
||||||
|
2. **See warning message**:
|
||||||
|
- **Expected**: Orange warning box with "Článek ještě není uložen"
|
||||||
|
- Button: "Uložit jako koncept a přidat ankety"
|
||||||
|
|
||||||
|
3. **Save as draft**:
|
||||||
|
- Click the button
|
||||||
|
- **Expected**:
|
||||||
|
- Article saves successfully
|
||||||
|
- Modal stays open
|
||||||
|
- Tab switches to Poll tab (still showing)
|
||||||
|
- PollLinker component now visible
|
||||||
|
- Can create/link polls
|
||||||
|
|
||||||
|
4. **Create poll**:
|
||||||
|
- Click "Vytvořit novou" tab
|
||||||
|
- Fill in poll title and options
|
||||||
|
- Click "Vytvořit anketu"
|
||||||
|
- **Expected**: Poll created and linked to article
|
||||||
|
|
||||||
|
### Test 3: Poll Creation for Existing Article
|
||||||
|
1. **Edit existing article**:
|
||||||
|
- Go to Admin → Články → Edit article
|
||||||
|
- Go to "Anketa" tab
|
||||||
|
|
||||||
|
2. **Expected**: PollLinker shows immediately (no warning)
|
||||||
|
3. Create or link polls as normal
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### PDF Viewing Methods
|
||||||
|
1. **Direct iframe** (default):
|
||||||
|
```html
|
||||||
|
<iframe src="/uploads/file.pdf#view=FitH&toolbar=1" />
|
||||||
|
```
|
||||||
|
- Works if CSP allows
|
||||||
|
- Fastest method
|
||||||
|
- Native browser PDF viewer
|
||||||
|
|
||||||
|
2. **Mozilla PDF.js** (fallback 1):
|
||||||
|
```
|
||||||
|
https://mozilla.github.io/pdf.js/web/viewer.html?file=<encoded_url>
|
||||||
|
```
|
||||||
|
- Works even with strict CSP
|
||||||
|
- JavaScript-based PDF renderer
|
||||||
|
- Requires internet connection
|
||||||
|
|
||||||
|
3. **Google Docs Viewer** (fallback 2):
|
||||||
|
```
|
||||||
|
https://docs.google.com/viewer?url=<encoded_url>&embedded=true
|
||||||
|
```
|
||||||
|
- Requires public URL
|
||||||
|
- May have privacy concerns
|
||||||
|
- Reliable for most PDFs
|
||||||
|
|
||||||
|
4. **Direct download** (fallback 3):
|
||||||
|
- Always works
|
||||||
|
- User opens in their PDF app
|
||||||
|
|
||||||
|
### Poll Creation Flow
|
||||||
|
```
|
||||||
|
New Article (no ID yet)
|
||||||
|
↓
|
||||||
|
User clicks "Save as draft and add polls"
|
||||||
|
↓
|
||||||
|
onSubmit({ keepOpen: true })
|
||||||
|
↓
|
||||||
|
Article created in database
|
||||||
|
↓
|
||||||
|
Modal stays open (closeModal not called)
|
||||||
|
↓
|
||||||
|
setActiveTabIndex(5) - Switch to Poll tab
|
||||||
|
↓
|
||||||
|
PollLinker now has articleId
|
||||||
|
↓
|
||||||
|
User can create/link polls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
### PDF Preview
|
||||||
|
- PDF.js fallback requires internet connection
|
||||||
|
- Google Viewer requires publicly accessible URLs
|
||||||
|
- Some browsers may block cross-origin iframes
|
||||||
|
- Very large PDFs (>10MB) may be slow
|
||||||
|
|
||||||
|
### Poll Creation
|
||||||
|
- Article must still be saved before linking polls (can't be fully offline)
|
||||||
|
- "Keep open" mode doesn't work if there's a network error
|
||||||
|
- If save fails, modal closes and poll tab doesn't appear
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
### PDF Preview
|
||||||
|
- ✅ Chrome/Edge: Native PDF viewer works
|
||||||
|
- ✅ Firefox: Native PDF viewer works
|
||||||
|
- ✅ Safari: Native PDF viewer works
|
||||||
|
- ✅ All browsers: Fallback buttons work
|
||||||
|
|
||||||
|
### Poll Creation
|
||||||
|
- ✅ All modern browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
- ✅ Mobile browsers
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
### PDF Preview
|
||||||
|
- [ ] Add PDF.js embed directly in application (no external CDN)
|
||||||
|
- [ ] Add thumbnail generation for PDFs
|
||||||
|
- [ ] Add page navigation controls
|
||||||
|
- [ ] Add zoom controls
|
||||||
|
- [ ] Add print button
|
||||||
|
|
||||||
|
### Poll Creation
|
||||||
|
- [ ] Allow poll creation before article save (store in temp state)
|
||||||
|
- [ ] Add poll preview in article form
|
||||||
|
- [ ] Bulk poll creation/linking
|
||||||
|
- [ ] Poll templates
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
- `frontend/src/components/common/FilePreview.tsx` - PDF preview component
|
||||||
|
- `frontend/src/components/admin/PollLinker.tsx` - Poll management component
|
||||||
|
- `frontend/src/pages/admin/ArticlesAdminPage.tsx` - Article editor
|
||||||
|
- `internal/controllers/base_controller.go` - Backend file upload handler
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
- `POST /api/v1/upload` - File upload (including PDFs)
|
||||||
|
- `GET /uploads/**` - Serve uploaded files
|
||||||
|
- `POST /api/v1/polls` - Create poll
|
||||||
|
- `PUT /api/v1/polls/:id` - Update poll (link to article)
|
||||||
|
- `GET /api/v1/polls?article_id=X` - Get polls for article
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Rich Text Editor Image Insertion Fix
|
||||||
|
|
||||||
|
**Date**: January 2025
|
||||||
|
**Issue**: Quill.js image insertion errors and image duplication on drag
|
||||||
|
|
||||||
|
## Problems Fixed
|
||||||
|
|
||||||
|
### 1. Quill.js Emitter Error
|
||||||
|
**Error Message**: `Uncaught TypeError: can't access property "emit", this.emitter is undefined`
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
- The Quill editor's internal state wasn't fully initialized when `insertEmbed` was called
|
||||||
|
- Calling `insertEmbed` immediately after upload without ensuring the editor is ready caused the emitter to be undefined
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// Insert into editor
|
||||||
|
const quill = quillRef.current?.getEditor();
|
||||||
|
if (quill) {
|
||||||
|
// Ensure editor is focused and ready
|
||||||
|
quill.focus();
|
||||||
|
|
||||||
|
// Use setTimeout to ensure Quill's internal state is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const range = quill.getSelection();
|
||||||
|
const index = range ? range.index : quill.getLength();
|
||||||
|
|
||||||
|
// Insert the image with 'api' source to prevent event loops
|
||||||
|
quill.insertEmbed(index, 'image', res.url, 'api');
|
||||||
|
|
||||||
|
// Move cursor after the image
|
||||||
|
quill.setSelection(index + 1, 0, 'api');
|
||||||
|
|
||||||
|
// Force content change to trigger re-render
|
||||||
|
onChangeRef.current(quill.root.innerHTML);
|
||||||
|
|
||||||
|
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
||||||
|
} catch (embedError) {
|
||||||
|
console.error('Error inserting image:', embedError);
|
||||||
|
toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Changes**:
|
||||||
|
- Added `quill.focus()` before insertion to ensure the editor has focus
|
||||||
|
- Wrapped insertion in `setTimeout(50ms)` to allow Quill's internal state to stabilize
|
||||||
|
- Changed source parameter from `'user'` to `'api'` to prevent triggering user-initiated event handlers
|
||||||
|
- Added try-catch block for better error handling
|
||||||
|
- Force content update with `onChangeRef.current(quill.root.innerHTML)` to trigger re-render
|
||||||
|
|
||||||
|
### 2. Image Not Showing After Insertion
|
||||||
|
**Problem**: After uploading and inserting an image, it didn't appear in the editor
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Added explicit `onChangeRef.current(quill.root.innerHTML)` call after insertion
|
||||||
|
- This forces React to recognize the DOM change and re-render the component
|
||||||
|
- The `'api'` source parameter prevents infinite loops while still triggering the necessary updates
|
||||||
|
|
||||||
|
### 3. Image Duplication on Drag
|
||||||
|
**Problem**: Dragging images created duplicates due to default browser drag-and-drop behavior
|
||||||
|
|
||||||
|
**Solution (Multiple Layers)**:
|
||||||
|
|
||||||
|
#### A. Set draggable attribute on image selection
|
||||||
|
```typescript
|
||||||
|
const selectImage = (img: HTMLImageElement) => {
|
||||||
|
// ... other code ...
|
||||||
|
|
||||||
|
// Prevent default drag behavior to avoid duplication
|
||||||
|
img.setAttribute('draggable', 'false');
|
||||||
|
|
||||||
|
createResizeHandle(img);
|
||||||
|
// ... rest of code ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Added dragstart event listener
|
||||||
|
```typescript
|
||||||
|
// Prevent default drag behavior on images
|
||||||
|
const handleDragStart = (e: DragEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'IMG') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.root.addEventListener('dragstart', handleDragStart);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Added CSS to disable drag
|
||||||
|
```typescript
|
||||||
|
img: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'block',
|
||||||
|
margin: '12px 0',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
borderRadius: '4px',
|
||||||
|
userSelect: 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
WebkitUserDrag: 'none', // Disable WebKit drag
|
||||||
|
userDrag: 'none', // Disable standard drag
|
||||||
|
// ... hover styles ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### `/frontend/src/components/common/CustomRichEditor.tsx`
|
||||||
|
|
||||||
|
**Lines 274-301**: Image insertion with proper Quill initialization
|
||||||
|
**Lines 523-524**: Set draggable="false" on image selection
|
||||||
|
**Lines 735-743**: Added dragstart event handler
|
||||||
|
**Lines 755**: Added dragstart cleanup in useEffect return
|
||||||
|
**Lines 1146-1147**: Added CSS properties to disable drag
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. **Test Image Insertion**:
|
||||||
|
- Open any admin page with rich text editor (Articles, Activities, About)
|
||||||
|
- Click "Vložit obrázek" button
|
||||||
|
- Upload an image and crop it
|
||||||
|
- Verify image appears immediately in the editor
|
||||||
|
- Check browser console for no errors
|
||||||
|
|
||||||
|
2. **Test Image Drag Behavior**:
|
||||||
|
- Insert an image in the editor
|
||||||
|
- Click to select the image (blue outline appears)
|
||||||
|
- Try to drag the image left/right
|
||||||
|
- Verify alignment changes but no duplicate is created
|
||||||
|
- Verify the image doesn't create a "ghost" drag preview
|
||||||
|
|
||||||
|
3. **Test Image Resize**:
|
||||||
|
- Select an image
|
||||||
|
- Grab the blue corner/edge handles
|
||||||
|
- Resize the image
|
||||||
|
- Verify smooth resizing without duplication
|
||||||
|
|
||||||
|
4. **Test Multiple Images**:
|
||||||
|
- Insert several images
|
||||||
|
- Interact with each one
|
||||||
|
- Verify no interference between images
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
The fixes use standard web APIs and should work in:
|
||||||
|
- ✅ Chrome/Edge (Chromium-based)
|
||||||
|
- ✅ Firefox
|
||||||
|
- ✅ Safari (WebKit)
|
||||||
|
|
||||||
|
The `-webkit-user-drag` CSS property specifically targets WebKit browsers.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- The 50ms delay in image insertion is a workaround for Quill's initialization timing
|
||||||
|
- If issues persist, the delay can be increased to 100ms, but this may cause noticeable lag
|
||||||
|
- The drag prevention is comprehensive but may interfere with future drag-and-drop features if needed
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `/frontend/src/components/common/RichTextEditor.tsx` - Wrapper component
|
||||||
|
- `/frontend/src/services/imageProcessing.ts` - Image upload/crop backend services
|
||||||
|
- `/frontend/src/styles/custom-editor.css` - Additional editor styles
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
1. Consider migrating to Quill 2.0 when stable (better event handling)
|
||||||
|
2. Add loading state during image upload to prevent multiple insertions
|
||||||
|
3. Add image compression options in the crop modal
|
||||||
|
4. Consider lazy loading for large images
|
||||||
Vendored
-39
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"ID": 1,
|
|
||||||
"CreatedAt": "2025-10-19T11:57:20.992362Z",
|
|
||||||
"UpdatedAt": "2025-10-19T11:57:20.992362Z",
|
|
||||||
"DeletedAt": null,
|
|
||||||
"title": "U17: Rýmařov potrestal naše chyby, z Polanky chceme tři body",
|
|
||||||
"content": "\u003ch2\u003eU17: Rýmařov potrestal naše chyby, z Polanky chceme tři body\u003c/h2\u003e\u003cp\u003eV sobotu jsme odehráli mistrovské utkání na půdě Rýmařova, které jsme bohužel prohráli 2:5. Hra měla pro nás smíšený průběh, ale především nám chyběla soustředěnost a kvalitní obrana.\u003c/p\u003e\u003ch3\u003ePrvní poločas: Špatný start a rychlé góly soupeře\u003c/h3\u003e\u003cp\u003ePrvní poločas nám vůbec nevyšel – byli jsme málo aktivní a dopouštěli se zbytečných chyb v obraně, zejména při nákopech soupeře za naši defenzivu. Rýmařov vsadil na jednoduchý, ale účinný styl hry: získat míč a okamžitě ho poslat dopředu. Na tento způsob hry jsme v úvodu nenašli odpověď. Soupeř využil naše chyby a rychle se dostal do vedení.\u003c/p\u003e\u003ch3\u003eDruhý poločas: Zlepšení, ale pozdě\u003c/h3\u003e\u003cp\u003eO poločase jsme si jasně řekli, co je potřeba změnit. Do druhého dějství jsme vstoupili mnohem lépe a hned na jeho začátku jsme měli velkou šanci, kdy jsme šli sami na brankáře – bohužel bez gólového efektu. Krátce poté soupeř přidal čtvrtou branku, ale náš tým to nezlomilo a během dvou minut jsme odpověděli snížením.\u003c/p\u003e\u003cp\u003eDruhý poločas byl z naší strany výrazně lepší – více pohybu, nasazení i snahy o kombinaci. Přesto se nám skóre nepodařilo otočit a soupeř v závěru přidal ještě pátý gól.\u003c/p\u003e\u003ch3\u003eAnalýza zápasu\u003c/h3\u003e\u003cp\u003eCelkově jsme udělali příliš mnoho chyb, byli málo důrazní a prohrávali osobní souboje. Kdybychom proměnili šanci hned po přestávce a snížili na 2:3, mohl zápas vypadat úplně jinak. Soupeř byl efektivnější a lépe využíval naše chybné momenty.\u003c/p\u003e\u003ch3\u003ePohled do budoucna\u003c/h3\u003e\u003cp\u003eNevěšíme hlavu — zapracujeme na nedostatcích, připravíme se poctivě a doma proti Polance uděláme maximum pro zisk tří bodů! V další úterý nás čeká důležitý domácí zápas, který bude mít klíčový význam pro naše další postupy v soutěži.\u003c/p\u003e\u003cp\u003eMichal Vala – U17\u003c/p\u003e\u003cp\u003e\u003cimg src=\"https://eu.zonerama.com/photos/565775563_1500x1000.jpg\" alt=\"Gallery photo\"\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e",
|
|
||||||
"author_id": 1,
|
|
||||||
"category_id": 1,
|
|
||||||
"image_url": "https://eu.zonerama.com/photos/565775554_1500x1000.jpg",
|
|
||||||
"published": true,
|
|
||||||
"published_at": "2025-10-19T11:57:20.992143Z",
|
|
||||||
"slug": "u17-rymarov-chyby-polanka",
|
|
||||||
"excerpt": "",
|
|
||||||
"featured": true,
|
|
||||||
"seo_title": "U17: Rýmařov potrestal naše chyby, z Polanky chceme tři body | Fotbalový klub",
|
|
||||||
"seo_description": "Přečtěte si více o u17: rýmařov potrestal naše chyby, z polanky chceme tři body. Aktuální informace, novinky a zajímavosti z našeho fotbalového klubu.",
|
|
||||||
"og_image_url": "https://eu.zonerama.com/photos/565775554_1500x1000.jpg",
|
|
||||||
"external_link": "",
|
|
||||||
"view_count": 0,
|
|
||||||
"read_time": 2,
|
|
||||||
"unique_views": 0,
|
|
||||||
"category_name": "",
|
|
||||||
"attachments": "",
|
|
||||||
"gallery_album_id": "13903610",
|
|
||||||
"gallery_album_url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903610",
|
|
||||||
"gallery_photo_ids": "565775563,565775549",
|
|
||||||
"youtube_video_id": "_OsRmfYOXJ4",
|
|
||||||
"youtube_video_title": "Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25",
|
|
||||||
"youtube_video_url": "https://www.youtube.com/watch?v=_OsRmfYOXJ4",
|
|
||||||
"youtube_video_thumbnail": "https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 1,
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:34:56Z","last_modified":""}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
[{"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
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:34:56Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:34:56Z","last_modified":""}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:35:00Z","last_modified":""}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:35:00Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
null
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"lastUpdated":"2025-10-19T15:35:00Z"}
|
|
||||||
Vendored
-47
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"baseURL": "http://127.0.0.1:8080/api/v1",
|
|
||||||
"duration_ms": 3857,
|
|
||||||
"endpoints": [
|
|
||||||
{
|
|
||||||
"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": "/articles?page=1\u0026page_size=10\u0026published=true",
|
|
||||||
"file": "articles.json",
|
|
||||||
"ok": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58/table",
|
|
||||||
"file": "facr_tables.json",
|
|
||||||
"ok": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
|
|
||||||
"file": "facr_club_info.json",
|
|
||||||
"ok": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastUpdated": "2025-10-19T15:35:00Z"
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"about_html":"","accent_color":"#ffb500","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":"Úvoz","contact_city":"Krnov","contact_country":"Česko","contact_email":"info@tdvorak.dev","contact_phone":"+420 778 701 838","contact_zip":"794 01","custom_nav":null,"facebook_url":"https://www.facebook.com/p/FK-Kofola-Krnov-61561103731912/","font_body":"Archivo","font_heading":"Archivo","gallery_label":"Fotogalerie","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":50.0860754,"location_longitude":17.6699647,"map_style":"dark","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","primary_color":"#ffd900","secondary_color":"#0002ff","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-09-28","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-19","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-19","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
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:34:56Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:34:56Z","last_modified":""}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"by_name":{}}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-19T15:34:56Z","last_modified":""}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
{"fetched_at":"2025-10-19T12:25:01Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "565775554",
|
|
||||||
"album_id": "",
|
|
||||||
"album_url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903610",
|
|
||||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775554",
|
|
||||||
"image_url": "https://eu.zonerama.com/photos/565775554_1500x1000.jpg",
|
|
||||||
"title": "",
|
|
||||||
"picked_at": "2025-10-19T11:57:11Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
Vendored
-102
@@ -1,102 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "",
|
|
||||||
"title": "",
|
|
||||||
"url": "",
|
|
||||||
"date": "",
|
|
||||||
"photos_count": 0,
|
|
||||||
"views_count": 0,
|
|
||||||
"photos": null,
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
null
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"fetched_at": "2025-10-19T12:25:21Z",
|
|
||||||
"link": ""
|
|
||||||
}
|
|
||||||
Vendored
-1071
File diff suppressed because it is too large
Load Diff
@@ -274,11 +274,30 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
// Insert into editor
|
// Insert into editor
|
||||||
const quill = quillRef.current?.getEditor();
|
const quill = quillRef.current?.getEditor();
|
||||||
if (quill) {
|
if (quill) {
|
||||||
const range = quill.getSelection(true);
|
// Ensure editor is focused and ready
|
||||||
const index = range ? range.index : quill.getLength();
|
quill.focus();
|
||||||
quill.insertEmbed(index, 'image', res.url, 'user');
|
|
||||||
quill.setSelection(index + 1, 0);
|
// Use setTimeout to ensure Quill's internal state is ready
|
||||||
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const range = quill.getSelection();
|
||||||
|
const index = range ? range.index : quill.getLength();
|
||||||
|
|
||||||
|
// Insert the image
|
||||||
|
quill.insertEmbed(index, 'image', res.url, 'api');
|
||||||
|
|
||||||
|
// Move cursor after the image
|
||||||
|
quill.setSelection(index + 1, 0, 'api');
|
||||||
|
|
||||||
|
// Force content change to trigger re-render
|
||||||
|
onChangeRef.current(quill.root.innerHTML);
|
||||||
|
|
||||||
|
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
||||||
|
} catch (embedError) {
|
||||||
|
console.error('Error inserting image:', embedError);
|
||||||
|
toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Crop and insert error:', e);
|
console.error('Crop and insert error:', e);
|
||||||
@@ -500,6 +519,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
img.style.outline = '3px solid #3182ce';
|
img.style.outline = '3px solid #3182ce';
|
||||||
img.style.cursor = 'move';
|
img.style.cursor = 'move';
|
||||||
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
||||||
|
|
||||||
|
// Prevent default drag behavior to avoid duplication
|
||||||
|
img.setAttribute('draggable', 'false');
|
||||||
|
|
||||||
createResizeHandle(img);
|
createResizeHandle(img);
|
||||||
|
|
||||||
// Set selected image state and load filters
|
// Set selected image state and load filters
|
||||||
@@ -639,7 +662,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
currentAlignment = 'right';
|
currentAlignment = 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable default drag behavior to prevent ghost image
|
// Already set in selectImage, but ensure it's off
|
||||||
target.setAttribute('draggable', 'false');
|
target.setAttribute('draggable', 'false');
|
||||||
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
@@ -709,15 +732,27 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prevent default drag behavior on images
|
||||||
|
const handleDragStart = (e: DragEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'IMG') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
editor.root.addEventListener('click', handleImageClick);
|
editor.root.addEventListener('click', handleImageClick);
|
||||||
editor.root.addEventListener('mousedown', handleMouseDown);
|
editor.root.addEventListener('mousedown', handleMouseDown);
|
||||||
editor.root.addEventListener('scroll', handleScroll);
|
editor.root.addEventListener('scroll', handleScroll);
|
||||||
|
editor.root.addEventListener('dragstart', handleDragStart);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
editor.root.removeEventListener('click', handleImageClick);
|
editor.root.removeEventListener('click', handleImageClick);
|
||||||
editor.root.removeEventListener('mousedown', handleMouseDown);
|
editor.root.removeEventListener('mousedown', handleMouseDown);
|
||||||
editor.root.removeEventListener('scroll', handleScroll);
|
editor.root.removeEventListener('scroll', handleScroll);
|
||||||
|
editor.root.removeEventListener('dragstart', handleDragStart);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
removeResizeHandle();
|
removeResizeHandle();
|
||||||
deselectImage();
|
deselectImage();
|
||||||
@@ -1107,6 +1142,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
WebkitUserDrag: 'none',
|
||||||
|
userDrag: 'none',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
transform: 'scale(1.01)',
|
transform: 'scale(1.01)',
|
||||||
|
|||||||
@@ -122,14 +122,70 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileInfo.type === 'pdf') {
|
if (fileInfo.type === 'pdf') {
|
||||||
|
// Try multiple PDF viewing methods due to CSP restrictions
|
||||||
return (
|
return (
|
||||||
<AspectRatio ratio={8.5 / 11} w="100%" minH="70vh">
|
<VStack spacing={4} w="100%" minH="70vh">
|
||||||
<iframe
|
{/* Primary: Try direct iframe embed */}
|
||||||
src={`${fullUrl}#view=FitH`}
|
<Box w="100%" h="70vh" borderWidth="1px" borderRadius="md" overflow="hidden">
|
||||||
title={fileName}
|
<iframe
|
||||||
style={{ border: 'none', width: '100%', height: '100%' }}
|
src={`${fullUrl}#view=FitH&toolbar=1`}
|
||||||
/>
|
title={fileName}
|
||||||
</AspectRatio>
|
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('PDF iframe load error:', e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Fallback options */}
|
||||||
|
<VStack spacing={2} w="100%">
|
||||||
|
<Text fontSize="sm" color={mutedText} textAlign="center">
|
||||||
|
Pokud se PDF nezobrazuje, použijte jedno z tlačítek níže:
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={3} flexWrap="wrap" justify="center">
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiEye />}
|
||||||
|
colorScheme="blue"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Otevřít v novém okně
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={`https://mozilla.github.io/pdf.js/web/viewer.html?file=${encodeURIComponent(fullUrl)}`}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiEye />}
|
||||||
|
colorScheme="purple"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Zobrazit pomocí PDF.js
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={`https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`}
|
||||||
|
isExternal
|
||||||
|
leftIcon={<FiEye />}
|
||||||
|
colorScheme="green"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Zobrazit přes Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={ChakraLink}
|
||||||
|
href={fullUrl}
|
||||||
|
download
|
||||||
|
leftIcon={<FiDownload />}
|
||||||
|
colorScheme="gray"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Stáhnout PDF
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { sortCategoriesWithOrder } from '../../utils/categorySort';
|
|||||||
import { sanitizeClubName } from '../../utils/url';
|
import { sanitizeClubName } from '../../utils/url';
|
||||||
import '../../styles/logos.css';
|
import '../../styles/logos.css';
|
||||||
|
|
||||||
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
|
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string; clubId?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName, clubId }) => (
|
||||||
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
|
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
|
||||||
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
|
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
|
||||||
<HStack flex={1} justify="flex-end" spacing={4}>
|
<HStack flex={1} justify="flex-end" spacing={4}>
|
||||||
@@ -28,14 +28,30 @@ const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string
|
|||||||
<HStack minW="60px" justify="center" spacing={2}>
|
<HStack minW="60px" justify="center" spacing={2}>
|
||||||
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
|
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!s || !clubName) return null;
|
if (!s) return null;
|
||||||
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
|
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
|
||||||
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
|
|
||||||
const strip = (x: string) => norm(x).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
// First try ID-based matching (most reliable)
|
||||||
const ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
let ourHome = false;
|
||||||
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
let ourAway = false;
|
||||||
|
if (clubId && hid && aid) {
|
||||||
|
ourHome = hid === clubId;
|
||||||
|
ourAway = aid === clubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to name matching if IDs not available or no match
|
||||||
|
if (!ourHome && !ourAway && clubName) {
|
||||||
|
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
|
||||||
|
const strip = (x: string) => norm(x).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
||||||
|
const A = strip(h);
|
||||||
|
const B = strip(clubName);
|
||||||
|
ourHome = Boolean(A && B && (A===B || A.endsWith(B) || B.endsWith(A)));
|
||||||
|
const C = strip(a);
|
||||||
|
ourAway = Boolean(C && B && (C===B || C.endsWith(B) || B.endsWith(C)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!ourHome && !ourAway) return null;
|
if (!ourHome && !ourAway) return null;
|
||||||
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
|
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
|
||||||
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
|
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
|
||||||
@@ -135,7 +151,7 @@ const CompetitionMatches: React.FC = () => {
|
|||||||
<TabPanel key={c.id} px={0}>
|
<TabPanel key={c.id} px={0}>
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
{(c.matches || []).slice(0, 6).map((m, idx) => (
|
{(c.matches || []).slice(0, 6).map((m, idx) => (
|
||||||
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} />
|
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} clubId={data.club_internal_id} />
|
||||||
))}
|
))}
|
||||||
{(c.matches || []).length === 0 && (
|
{(c.matches || []).length === 0 && (
|
||||||
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
|
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const MatchRow: React.FC<{
|
|||||||
away: { name: string; logo?: string; id?: string };
|
away: { name: string; logo?: string; id?: string };
|
||||||
score?: string;
|
score?: string;
|
||||||
clubName?: string;
|
clubName?: string;
|
||||||
}> = ({ date, home, away, score, clubName }) => (
|
clubId?: string;
|
||||||
|
}> = ({ date, home, away, score, clubName, clubId }) => (
|
||||||
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
|
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
|
||||||
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
|
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
|
||||||
<HStack flex={1} justify="flex-end">
|
<HStack flex={1} justify="flex-end">
|
||||||
@@ -32,14 +33,30 @@ const MatchRow: React.FC<{
|
|||||||
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
|
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
|
||||||
{(() => {
|
{(() => {
|
||||||
const sent = (() => {
|
const sent = (() => {
|
||||||
if (!score || !clubName) return null;
|
if (!score) return null;
|
||||||
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
|
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
|
||||||
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
|
|
||||||
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
// First try ID-based matching (most reliable)
|
||||||
const ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
let ourIsHome = false;
|
||||||
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
let ourIsAway = false;
|
||||||
|
if (clubId && home.id && away.id) {
|
||||||
|
ourIsHome = home.id === clubId;
|
||||||
|
ourIsAway = away.id === clubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to name matching if IDs not available or no match
|
||||||
|
if (!ourIsHome && !ourIsAway && clubName) {
|
||||||
|
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
|
||||||
|
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
||||||
|
const aName = strip(home.name);
|
||||||
|
const bName = strip(clubName);
|
||||||
|
ourIsHome = Boolean(aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)));
|
||||||
|
const cName = strip(away.name);
|
||||||
|
ourIsAway = Boolean(cName && bName && (cName===bName || cName.endsWith(bName) || bName.endsWith(cName)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!ourIsHome && !ourIsAway) return null;
|
if (!ourIsHome && !ourIsAway) return null;
|
||||||
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
|
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
|
||||||
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
|
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
|
||||||
@@ -100,6 +117,7 @@ const MatchesSection: React.FC = () => {
|
|||||||
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
|
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
|
||||||
score={m.score}
|
score={m.score}
|
||||||
clubName={data.name}
|
clubName={data.name}
|
||||||
|
clubId={data.club_internal_id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{(c.matches || []).length === 0 && (
|
{(c.matches || []).length === 0 && (
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type MatchItem = {
|
|||||||
time: string; // HH:MM
|
time: string; // HH:MM
|
||||||
home: string;
|
home: string;
|
||||||
away: string;
|
away: string;
|
||||||
|
home_id?: string;
|
||||||
|
away_id?: string;
|
||||||
venue?: string;
|
venue?: string;
|
||||||
home_logo_url?: string;
|
home_logo_url?: string;
|
||||||
away_logo_url?: string;
|
away_logo_url?: string;
|
||||||
@@ -51,6 +53,7 @@ const CalendarPage: React.FC = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [clubName, setClubName] = useState<string>('');
|
const [clubName, setClubName] = useState<string>('');
|
||||||
|
const [clubId, setClubId] = useState<string>('');
|
||||||
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
|
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
|
||||||
const [standings, setStandings] = useState<any[]>([]);
|
const [standings, setStandings] = useState<any[]>([]);
|
||||||
|
|
||||||
@@ -264,6 +267,8 @@ const CalendarPage: React.FC = () => {
|
|||||||
time,
|
time,
|
||||||
home: m.home,
|
home: m.home,
|
||||||
away: m.away,
|
away: m.away,
|
||||||
|
home_id: m.home_id,
|
||||||
|
away_id: m.away_id,
|
||||||
venue: m.venue,
|
venue: m.venue,
|
||||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||||
@@ -307,6 +312,8 @@ const CalendarPage: React.FC = () => {
|
|||||||
time,
|
time,
|
||||||
home: m.home,
|
home: m.home,
|
||||||
away: m.away,
|
away: m.away,
|
||||||
|
home_id: m.home_id,
|
||||||
|
away_id: m.away_id,
|
||||||
venue: m.venue,
|
venue: m.venue,
|
||||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||||
@@ -399,6 +406,7 @@ const CalendarPage: React.FC = () => {
|
|||||||
setCompLinks(compLinkMap);
|
setCompLinks(compLinkMap);
|
||||||
setStandings(standingsData);
|
setStandings(standingsData);
|
||||||
if (json?.name) setClubName(String(json.name));
|
if (json?.name) setClubName(String(json.name));
|
||||||
|
if (json?.club_internal_id) setClubId(String(json.club_internal_id));
|
||||||
if (json?.club_type) setClubType(json.club_type);
|
if (json?.club_type) setClubType(json.club_type);
|
||||||
// Set active tab from query ?comp=<id>
|
// Set active tab from query ?comp=<id>
|
||||||
const compQ = searchParams.get('comp');
|
const compQ = searchParams.get('comp');
|
||||||
@@ -521,8 +529,21 @@ const CalendarPage: React.FC = () => {
|
|||||||
|
|
||||||
const s = parseScore(m.score);
|
const s = parseScore(m.score);
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const ourIsHome = isClubTeam(m.home);
|
|
||||||
const ourIsAway = isClubTeam(m.away);
|
// First try ID-based matching (most reliable)
|
||||||
|
let ourIsHome = false;
|
||||||
|
let ourIsAway = false;
|
||||||
|
if (clubId && m.home_id && m.away_id) {
|
||||||
|
ourIsHome = m.home_id === clubId;
|
||||||
|
ourIsAway = m.away_id === clubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to name matching if IDs not available or no match
|
||||||
|
if (!ourIsHome && !ourIsAway) {
|
||||||
|
ourIsHome = isClubTeam(m.home);
|
||||||
|
ourIsAway = isClubTeam(m.away);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ourIsHome && !ourIsAway) return null; // unknown perspective
|
if (!ourIsHome && !ourIsAway) return null; // unknown perspective
|
||||||
if (s.h === s.a) return { label: 'Remíza', color: 'blue' };
|
if (s.h === s.a) return { label: 'Remíza', color: 'blue' };
|
||||||
const ourGoals = ourIsHome ? s.h : s.a;
|
const ourGoals = ourIsHome ? s.h : s.a;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type MatchItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
home: string;
|
home: string;
|
||||||
away: string;
|
away: string;
|
||||||
|
home_id?: string;
|
||||||
|
away_id?: string;
|
||||||
home_logo_url?: string;
|
home_logo_url?: string;
|
||||||
away_logo_url?: string;
|
away_logo_url?: string;
|
||||||
score?: string | null;
|
score?: string | null;
|
||||||
@@ -23,7 +25,7 @@ type MatchItem = {
|
|||||||
venue?: string;
|
venue?: string;
|
||||||
competition?: string;
|
competition?: string;
|
||||||
competitionName?: string;
|
competitionName?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const MatchesPage: React.FC = () => {
|
const MatchesPage: React.FC = () => {
|
||||||
const [clubName, setClubName] = useState<string>('');
|
const [clubName, setClubName] = useState<string>('');
|
||||||
@@ -115,8 +117,21 @@ const MatchesPage: React.FC = () => {
|
|||||||
const getSentiment = (m: MatchItem): { label: 'Výhra'|'Remíza'|'Prohra'; colorScheme: 'green'|'blue'|'red' } | null => {
|
const getSentiment = (m: MatchItem): { label: 'Výhra'|'Remíza'|'Prohra'; colorScheme: 'green'|'blue'|'red' } | null => {
|
||||||
const s = parseScore(m.score);
|
const s = parseScore(m.score);
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const ourIsHome = isClubTeam(m.home);
|
|
||||||
const ourIsAway = isClubTeam(m.away);
|
// First try ID-based matching (most reliable)
|
||||||
|
let ourIsHome = false;
|
||||||
|
let ourIsAway = false;
|
||||||
|
if (clubId && m.home_id && m.away_id) {
|
||||||
|
ourIsHome = m.home_id === clubId;
|
||||||
|
ourIsAway = m.away_id === clubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to name matching if IDs not available or no match
|
||||||
|
if (!ourIsHome && !ourIsAway) {
|
||||||
|
ourIsHome = isClubTeam(m.home);
|
||||||
|
ourIsAway = isClubTeam(m.away);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ourIsHome && !ourIsAway) return null;
|
if (!ourIsHome && !ourIsAway) return null;
|
||||||
if (s.h === s.a) return { label: 'Remíza', colorScheme: 'blue' };
|
if (s.h === s.a) return { label: 'Remíza', colorScheme: 'blue' };
|
||||||
const ourGoals = ourIsHome ? s.h : s.a;
|
const ourGoals = ourIsHome ? s.h : s.a;
|
||||||
@@ -343,6 +358,8 @@ const MatchesPage: React.FC = () => {
|
|||||||
time,
|
time,
|
||||||
home: m.home,
|
home: m.home,
|
||||||
away: m.away,
|
away: m.away,
|
||||||
|
home_id: m.home_id,
|
||||||
|
away_id: m.away_id,
|
||||||
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
|
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
|
||||||
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
||||||
score: actualScore,
|
score: actualScore,
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show loading state while fetching
|
||||||
|
if (linkQ.isLoading) {
|
||||||
|
return <Badge colorScheme="gray">Načítání...</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
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>;
|
||||||
|
|
||||||
@@ -651,38 +657,19 @@ const ArticlesAdminPage = () => {
|
|||||||
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
||||||
createArticle(payload),
|
createArticle(payload),
|
||||||
onSuccess: async (created: any) => {
|
onSuccess: async (created: any) => {
|
||||||
try {
|
console.log('Article created successfully in mutation callback:', created);
|
||||||
// If a match was selected (from temp storage), link it now that we have an article ID
|
// Note: Match linking is now handled in onSubmit() to avoid race conditions
|
||||||
const matchRaw = tempMatchLink || matchIdInput;
|
// Clear temporary storage
|
||||||
const matchToLink = typeof matchRaw === 'string' ? matchRaw : String(matchRaw || '');
|
setTempMatchLink('');
|
||||||
const matchId = matchToLink.trim();
|
setMatchIdInput('');
|
||||||
if (matchId && created?.id) {
|
|
||||||
await putArticleMatchLink(created.id, { external_match_id: matchId, title: (editing as any)?.title || '' });
|
// Invalidate queries to refresh the list
|
||||||
setLinkedMatchId(matchId);
|
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||||
toast({
|
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||||
title: 'Článek vytvořen a propojen se zápasem',
|
qc.invalidateQueries({ queryKey: ['recentArticles'] });
|
||||||
description: `Match ID: ${matchId}`,
|
qc.invalidateQueries({ queryKey: ['article-match-link'] }); // Invalidate match links
|
||||||
status: 'success',
|
|
||||||
duration: 3000,
|
// Don't close modal here - let onSubmit handle it after match linking
|
||||||
isClosable: true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'Článek byl úspěšně vytvořen',
|
|
||||||
status: 'success',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Clear temporary storage after successful creation
|
|
||||||
setTempMatchLink('');
|
|
||||||
} finally {
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['articles'] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['recentArticles'] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['article-match-link'] }); // Invalidate match links
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (e: any) => {
|
onError: (e: any) => {
|
||||||
console.error('Error creating article:', e);
|
console.error('Error creating article:', e);
|
||||||
@@ -702,18 +689,16 @@ const ArticlesAdminPage = () => {
|
|||||||
updateArticle(id, payload),
|
updateArticle(id, payload),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
const articleId = variables.id;
|
const articleId = variables.id;
|
||||||
toast({
|
console.log('Article updated successfully in mutation callback:', articleId);
|
||||||
title: 'Článek byl úspěšně aktualizován',
|
|
||||||
status: 'success',
|
// Invalidate queries to refresh the list
|
||||||
duration: 3000,
|
|
||||||
isClosable: true
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||||
qc.invalidateQueries({ queryKey: ['articles'] });
|
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||||
qc.invalidateQueries({ queryKey: ['recentArticles'] });
|
qc.invalidateQueries({ queryKey: ['recentArticles'] });
|
||||||
qc.invalidateQueries({ queryKey: ['article-match-link', articleId] }); // Invalidate specific match link
|
qc.invalidateQueries({ queryKey: ['article-match-link', articleId] }); // Invalidate specific match link
|
||||||
qc.invalidateQueries({ queryKey: ['article', `id:${articleId}`] }); // Invalidate article detail
|
qc.invalidateQueries({ queryKey: ['article', `id:${articleId}`] }); // Invalidate article detail
|
||||||
closeModal();
|
|
||||||
|
// Success toast and modal closing handled in onSubmit()
|
||||||
},
|
},
|
||||||
onError: (e: any) => {
|
onError: (e: any) => {
|
||||||
console.error('Error updating article:', e);
|
console.error('Error updating article:', e);
|
||||||
@@ -864,7 +849,7 @@ const ArticlesAdminPage = () => {
|
|||||||
} catch { return undefined; }
|
} catch { return undefined; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
// Require category selection by name (kategorie je povinná)
|
// Require category selection by name (kategorie je povinná)
|
||||||
if (!String((editing as any)?.category_name || '').trim()) {
|
if (!String((editing as any)?.category_name || '').trim()) {
|
||||||
@@ -932,10 +917,83 @@ const ArticlesAdminPage = () => {
|
|||||||
// Log the payload for debugging
|
// Log the payload for debugging
|
||||||
console.log('Saving article with payload:', JSON.stringify(payload, null, 2));
|
console.log('Saving article with payload:', JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
|
// Debug: Log match link state before submission
|
||||||
|
console.log('Match link state before submit:', {
|
||||||
|
tempMatchLink,
|
||||||
|
matchIdInput,
|
||||||
|
linkedMatchId,
|
||||||
|
isNewArticle: !(editing as any)?.id
|
||||||
|
});
|
||||||
|
|
||||||
if ((editing as any)?.id) {
|
if ((editing as any)?.id) {
|
||||||
|
// Update existing article
|
||||||
await updateMut.mutateAsync({ id: (editing as any).id, payload });
|
await updateMut.mutateAsync({ id: (editing as any).id, payload });
|
||||||
|
|
||||||
|
// Handle match linking for existing articles (update or delete)
|
||||||
|
const matchRaw = matchIdInput || linkedMatchId;
|
||||||
|
const matchId = String(matchRaw || '').trim();
|
||||||
|
let matchLinked = false;
|
||||||
|
if (matchId) {
|
||||||
|
try {
|
||||||
|
await putArticleMatchLink((editing as any).id, { external_match_id: matchId, title: editing.title || '' });
|
||||||
|
console.log('Match link updated for existing article');
|
||||||
|
matchLinked = true;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to update match link:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
toast({
|
||||||
|
title: matchLinked ? 'Článek aktualizován a propojen se zápasem' : 'Článek byl úspěšně aktualizován',
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await createMut.mutateAsync(payload);
|
// Create new article
|
||||||
|
const created = await createMut.mutateAsync(payload);
|
||||||
|
|
||||||
|
// Handle match linking for new articles
|
||||||
|
const matchRaw = tempMatchLink || matchIdInput;
|
||||||
|
const matchId = String(matchRaw || '').trim();
|
||||||
|
if (matchId && created?.id) {
|
||||||
|
console.log('Linking new article', created.id, 'with match', matchId);
|
||||||
|
try {
|
||||||
|
await putArticleMatchLink(created.id, { external_match_id: matchId, title: editing.title || '' });
|
||||||
|
console.log('Match link created for new article');
|
||||||
|
setLinkedMatchId(matchId);
|
||||||
|
toast({
|
||||||
|
title: 'Článek vytvořen a propojen se zápasem',
|
||||||
|
description: `Match ID: ${matchId}`,
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to link match:', err);
|
||||||
|
toast({
|
||||||
|
title: 'Článek vytvořen, ale propojení se zápasem selhalo',
|
||||||
|
description: err?.response?.data?.error || err?.message || 'Zkuste propojit zápas ručně',
|
||||||
|
status: 'warning',
|
||||||
|
duration: 5000,
|
||||||
|
isClosable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (created?.id) {
|
||||||
|
// No match to link, just show success
|
||||||
|
toast({
|
||||||
|
title: 'Článek byl úspěšně vytvořen',
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal after successful save (unless keepOpen is true)
|
||||||
|
if (!options.keepOpen) {
|
||||||
|
closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -1781,13 +1839,30 @@ const ArticlesAdminPage = () => {
|
|||||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<Alert status="info" borderRadius="md">
|
<Alert status="warning" borderRadius="md">
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<VStack align="start" spacing={1}>
|
<VStack align="start" spacing={2}>
|
||||||
<Text fontWeight="semibold">Nejprve uložte článek</Text>
|
<Text fontWeight="semibold">Článek ještě není uložen</Text>
|
||||||
<Text fontSize="sm">
|
<Text fontSize="sm">
|
||||||
Pro vytvoření nebo propojení ankety nejprve uložte článek tlačítkem "Uložit" níže. Poté se vrátíte do úprav a budete moci přidat ankety.
|
Pro propojení anket s článkem musíte nejprve článek uložit. Klikněte na "Uložit" níže - článek se uloží jako koncept a poté budete moci přidat ankety.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
colorScheme="blue"
|
||||||
|
onClick={async () => {
|
||||||
|
// Save article as draft first, keep modal open
|
||||||
|
try {
|
||||||
|
await onSubmit({ keepOpen: true });
|
||||||
|
// Switch to poll tab after save
|
||||||
|
setActiveTabIndex(5); // Poll tab is index 5
|
||||||
|
} catch (error) {
|
||||||
|
// Error is handled by onSubmit
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={createMut.isLoading}
|
||||||
|
>
|
||||||
|
Uložit jako koncept a přidat ankety
|
||||||
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -1798,7 +1873,7 @@ const ArticlesAdminPage = () => {
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
|
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
|
||||||
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
|
<Button colorScheme="blue" onClick={() => onSubmit()} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -404,13 +404,13 @@ func (pcr *PollControllerRefactored) GetPollResults(c *gin.Context) {
|
|||||||
for _, option := range poll.Options {
|
for _, option := range poll.Options {
|
||||||
var count int64
|
var count int64
|
||||||
pcr.DB.Model(&models.PollVote{}).Where("option_id = ?", option.ID).Count(&count)
|
pcr.DB.Model(&models.PollVote{}).Where("option_id = ?", option.ID).Count(&count)
|
||||||
|
|
||||||
results = append(results, OptionResult{
|
results = append(results, OptionResult{
|
||||||
OptionID: option.ID,
|
OptionID: option.ID,
|
||||||
OptionText: option.Text,
|
OptionText: option.Text,
|
||||||
VoteCount: count,
|
VoteCount: count,
|
||||||
})
|
})
|
||||||
|
|
||||||
totalVotes += count
|
totalVotes += count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user