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
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (quill) {
|
||||
const range = quill.getSelection(true);
|
||||
// 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();
|
||||
quill.insertEmbed(index, 'image', res.url, 'user');
|
||||
quill.setSelection(index + 1, 0);
|
||||
|
||||
// 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) {
|
||||
console.error('Crop and insert error:', e);
|
||||
@@ -500,6 +519,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
img.style.outline = '3px solid #3182ce';
|
||||
img.style.cursor = 'move';
|
||||
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);
|
||||
|
||||
// Set selected image state and load filters
|
||||
@@ -639,7 +662,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
currentAlignment = 'right';
|
||||
}
|
||||
|
||||
// Disable default drag behavior to prevent ghost image
|
||||
// Already set in selectImage, but ensure it's off
|
||||
target.setAttribute('draggable', 'false');
|
||||
|
||||
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('mousedown', handleMouseDown);
|
||||
editor.root.addEventListener('scroll', handleScroll);
|
||||
editor.root.addEventListener('dragstart', handleDragStart);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
editor.root.removeEventListener('click', handleImageClick);
|
||||
editor.root.removeEventListener('mousedown', handleMouseDown);
|
||||
editor.root.removeEventListener('scroll', handleScroll);
|
||||
editor.root.removeEventListener('dragstart', handleDragStart);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
removeResizeHandle();
|
||||
deselectImage();
|
||||
@@ -1107,6 +1142,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '4px',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'auto',
|
||||
WebkitUserDrag: 'none',
|
||||
userDrag: 'none',
|
||||
'&:hover': {
|
||||
opacity: 0.95,
|
||||
transform: 'scale(1.01)',
|
||||
|
||||
@@ -122,14 +122,70 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
}
|
||||
|
||||
if (fileInfo.type === 'pdf') {
|
||||
// Try multiple PDF viewing methods due to CSP restrictions
|
||||
return (
|
||||
<AspectRatio ratio={8.5 / 11} w="100%" minH="70vh">
|
||||
<VStack spacing={4} w="100%" minH="70vh">
|
||||
{/* Primary: Try direct iframe embed */}
|
||||
<Box w="100%" h="70vh" borderWidth="1px" borderRadius="md" overflow="hidden">
|
||||
<iframe
|
||||
src={`${fullUrl}#view=FitH`}
|
||||
src={`${fullUrl}#view=FitH&toolbar=1`}
|
||||
title={fileName}
|
||||
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||
onError={(e) => {
|
||||
console.error('PDF iframe load error:', e);
|
||||
}}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</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 '../../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">
|
||||
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
|
||||
<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}>
|
||||
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
|
||||
{(() => {
|
||||
if (!s || !clubName) return null;
|
||||
if (!s) return null;
|
||||
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
|
||||
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourHome = false;
|
||||
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 ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
||||
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
||||
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 (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
|
||||
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
|
||||
@@ -135,7 +151,7 @@ const CompetitionMatches: React.FC = () => {
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(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 && (
|
||||
<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 };
|
||||
score?: 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">
|
||||
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
|
||||
<HStack flex={1} justify="flex-end">
|
||||
@@ -32,14 +33,30 @@ const MatchRow: React.FC<{
|
||||
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
|
||||
{(() => {
|
||||
const sent = (() => {
|
||||
if (!score || !clubName) return null;
|
||||
if (!score) return null;
|
||||
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
|
||||
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourIsHome = false;
|
||||
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 ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
||||
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
||||
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 (h === a) return { label: 'Remíza', color: 'blue' } as const;
|
||||
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 }}
|
||||
score={m.score}
|
||||
clubName={data.name}
|
||||
clubId={data.club_internal_id}
|
||||
/>
|
||||
))}
|
||||
{(c.matches || []).length === 0 && (
|
||||
|
||||
@@ -22,6 +22,8 @@ type MatchItem = {
|
||||
time: string; // HH:MM
|
||||
home: string;
|
||||
away: string;
|
||||
home_id?: string;
|
||||
away_id?: string;
|
||||
venue?: string;
|
||||
home_logo_url?: string;
|
||||
away_logo_url?: string;
|
||||
@@ -51,6 +53,7 @@ const CalendarPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const toast = useToast();
|
||||
const [clubName, setClubName] = useState<string>('');
|
||||
const [clubId, setClubId] = useState<string>('');
|
||||
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
|
||||
const [standings, setStandings] = useState<any[]>([]);
|
||||
|
||||
@@ -264,6 +267,8 @@ const CalendarPage: React.FC = () => {
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
venue: m.venue,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||
@@ -307,6 +312,8 @@ const CalendarPage: React.FC = () => {
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
venue: m.venue,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||
@@ -399,6 +406,7 @@ const CalendarPage: React.FC = () => {
|
||||
setCompLinks(compLinkMap);
|
||||
setStandings(standingsData);
|
||||
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);
|
||||
// Set active tab from query ?comp=<id>
|
||||
const compQ = searchParams.get('comp');
|
||||
@@ -521,8 +529,21 @@ const CalendarPage: React.FC = () => {
|
||||
|
||||
const s = parseScore(m.score);
|
||||
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 (s.h === s.a) return { label: 'Remíza', color: 'blue' };
|
||||
const ourGoals = ourIsHome ? s.h : s.a;
|
||||
|
||||
@@ -15,6 +15,8 @@ type MatchItem = {
|
||||
time: string;
|
||||
home: string;
|
||||
away: string;
|
||||
home_id?: string;
|
||||
away_id?: string;
|
||||
home_logo_url?: string;
|
||||
away_logo_url?: string;
|
||||
score?: string | null;
|
||||
@@ -23,7 +25,7 @@ type MatchItem = {
|
||||
venue?: string;
|
||||
competition?: string;
|
||||
competitionName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MatchesPage: React.FC = () => {
|
||||
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 s = parseScore(m.score);
|
||||
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 (s.h === s.a) return { label: 'Remíza', colorScheme: 'blue' };
|
||||
const ourGoals = ourIsHome ? s.h : s.a;
|
||||
@@ -343,6 +358,8 @@ const MatchesPage: React.FC = () => {
|
||||
time,
|
||||
home: m.home,
|
||||
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),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
||||
score: actualScore,
|
||||
|
||||
@@ -36,6 +36,12 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
staleTime: 60_000,
|
||||
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;
|
||||
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
|
||||
createArticle(payload),
|
||||
onSuccess: async (created: any) => {
|
||||
try {
|
||||
// If a match was selected (from temp storage), link it now that we have an article ID
|
||||
const matchRaw = tempMatchLink || matchIdInput;
|
||||
const matchToLink = typeof matchRaw === 'string' ? matchRaw : String(matchRaw || '');
|
||||
const matchId = matchToLink.trim();
|
||||
if (matchId && created?.id) {
|
||||
await putArticleMatchLink(created.id, { external_match_id: matchId, title: (editing as any)?.title || '' });
|
||||
setLinkedMatchId(matchId);
|
||||
toast({
|
||||
title: 'Článek vytvořen a propojen se zápasem',
|
||||
description: `Match ID: ${matchId}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Článek byl úspěšně vytvořen',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
}
|
||||
// Clear temporary storage after successful creation
|
||||
console.log('Article created successfully in mutation callback:', created);
|
||||
// Note: Match linking is now handled in onSubmit() to avoid race conditions
|
||||
// Clear temporary storage
|
||||
setTempMatchLink('');
|
||||
} finally {
|
||||
setMatchIdInput('');
|
||||
|
||||
// Invalidate queries to refresh the list
|
||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||
qc.invalidateQueries({ queryKey: ['recentArticles'] });
|
||||
qc.invalidateQueries({ queryKey: ['article-match-link'] }); // Invalidate match links
|
||||
closeModal();
|
||||
}
|
||||
|
||||
// Don't close modal here - let onSubmit handle it after match linking
|
||||
},
|
||||
onError: (e: any) => {
|
||||
console.error('Error creating article:', e);
|
||||
@@ -702,18 +689,16 @@ const ArticlesAdminPage = () => {
|
||||
updateArticle(id, payload),
|
||||
onSuccess: (_, variables) => {
|
||||
const articleId = variables.id;
|
||||
toast({
|
||||
title: 'Článek byl úspěšně aktualizován',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
console.log('Article updated successfully in mutation callback:', articleId);
|
||||
|
||||
// Invalidate queries to refresh the list
|
||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||
qc.invalidateQueries({ queryKey: ['recentArticles'] });
|
||||
qc.invalidateQueries({ queryKey: ['article-match-link', articleId] }); // Invalidate specific match link
|
||||
qc.invalidateQueries({ queryKey: ['article', `id:${articleId}`] }); // Invalidate article detail
|
||||
closeModal();
|
||||
|
||||
// Success toast and modal closing handled in onSubmit()
|
||||
},
|
||||
onError: (e: any) => {
|
||||
console.error('Error updating article:', e);
|
||||
@@ -864,7 +849,7 @@ const ArticlesAdminPage = () => {
|
||||
} catch { return undefined; }
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
|
||||
if (!editing) return;
|
||||
// Require category selection by name (kategorie je povinná)
|
||||
if (!String((editing as any)?.category_name || '').trim()) {
|
||||
@@ -932,10 +917,83 @@ const ArticlesAdminPage = () => {
|
||||
// Log the payload for debugging
|
||||
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) {
|
||||
// Update existing article
|
||||
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 {
|
||||
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) {
|
||||
@@ -1781,13 +1839,30 @@ const ArticlesAdminPage = () => {
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="semibold">Nejprve uložte článek</Text>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="semibold">Článek ještě není uložen</Text>
|
||||
<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>
|
||||
<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>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -1798,7 +1873,7 @@ const ArticlesAdminPage = () => {
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<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>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user