diff --git a/config/migrate_add_media_items.sql b/config/migrate_add_media_items.sql new file mode 100644 index 0000000..3616ac3 --- /dev/null +++ b/config/migrate_add_media_items.sql @@ -0,0 +1,27 @@ +-- Migration: Add multi-media support to generated_posts +-- Date: 2026-02-11 +-- Description: Adds media_items JSONB array to support up to 3 images or 1 video per post + +-- Add media_items column +ALTER TABLE generated_posts ADD COLUMN IF NOT EXISTS media_items JSONB DEFAULT '[]'::JSONB; + +-- Add GIN index for performance on JSONB queries +CREATE INDEX IF NOT EXISTS idx_generated_posts_media_items ON generated_posts USING GIN (media_items); + +-- Migrate existing image_url to media_items array +UPDATE generated_posts +SET media_items = jsonb_build_array( + jsonb_build_object( + 'type', 'image', + 'url', image_url, + 'order', 0, + 'content_type', 'image/jpeg', + 'uploaded_at', NOW() + ) +) +WHERE image_url IS NOT NULL AND media_items = '[]'::JSONB; + +-- Note: image_url column is kept for backward compatibility +-- New code should use media_items, but existing code still works + +COMMENT ON COLUMN generated_posts.media_items IS 'JSONB array of media items (images/videos). Max 3 items. Structure: [{type, url, order, content_type, uploaded_at, metadata}]'; diff --git a/src/database/models.py b/src/database/models.py index a390e7c..2df83a1 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1,5 +1,5 @@ """Pydantic models for database entities.""" -from datetime import datetime, date +from datetime import datetime, date, timezone from enum import Enum from typing import Optional, Dict, Any, List from uuid import UUID @@ -312,6 +312,16 @@ class ApiUsageLog(DBModel): created_at: Optional[datetime] = None +class MediaItem(BaseModel): + """Single media item (image or video) for a post.""" + type: str # "image" | "video" + url: str + order: int + content_type: str # MIME type (e.g., 'image/jpeg', 'video/mp4') + uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + metadata: Optional[Dict[str, Any]] = None + + class GeneratedPost(DBModel): """Generated post model.""" id: Optional[UUID] = None @@ -327,7 +337,9 @@ class GeneratedPost(DBModel): approved_at: Optional[datetime] = None published_at: Optional[datetime] = None post_type_id: Optional[UUID] = None - # Image + # Media (multi-media support) + media_items: List[MediaItem] = Field(default_factory=list) + # DEPRECATED: Image (kept for backward compatibility) image_url: Optional[str] = None # Scheduling fields scheduled_at: Optional[datetime] = None @@ -335,6 +347,11 @@ class GeneratedPost(DBModel): # Metadata for additional info (e.g., LinkedIn post URL, auto-posting status) metadata: Optional[Dict[str, Any]] = None + @property + def has_media(self) -> bool: + """Check if post has any media items.""" + return len(self.media_items) > 0 + # ==================== LICENSE KEY MODELS ==================== diff --git a/src/services/linkedin_service.py b/src/services/linkedin_service.py index 4247d76..7a3acb5 100644 --- a/src/services/linkedin_service.py +++ b/src/services/linkedin_service.py @@ -35,7 +35,8 @@ class LinkedInService: self, linkedin_account_id: UUID, text: str, - image_url: Optional[str] = None + image_url: Optional[str] = None, + media_items: Optional[list] = None ) -> Dict: """ Post content to LinkedIn using UGC Posts API. @@ -43,7 +44,9 @@ class LinkedInService: Args: linkedin_account_id: ID of the linkedin_accounts record text: Post text content - image_url: Optional image URL from Supabase storage + image_url: (DEPRECATED) Optional single image URL - use media_items instead + media_items: Optional list of media items (dicts with 'type', 'url', 'order') + Supports up to 3 images (carousel) or 1 video Returns: Dict with 'url' key containing the LinkedIn post URL @@ -80,17 +83,53 @@ class LinkedInService: } } - # Handle image upload if provided - if image_url: + # Handle media upload (new multi-media support) + if media_items and len(media_items) > 0: + try: + media_type = media_items[0].get("type", "image") # All items have same type + + if media_type == "video": + # Single video upload + video_urn = await self._upload_video( + access_token, + linkedin_user_id, + media_items[0].get("url") + ) + if video_urn: + payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "VIDEO" + payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [ + {"status": "READY", "media": video_urn} + ] + + elif media_type == "image": + # Upload all images (Carousel if > 1) + media_urns = [] + for item in sorted(media_items, key=lambda x: x.get("order", 0)): + urn = await self._upload_image( + access_token, + linkedin_user_id, + item.get("url") + ) + if urn: + media_urns.append(urn) + + if media_urns: + payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE" + payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [ + {"status": "READY", "media": urn} for urn in media_urns + ] + + except Exception as e: + logger.warning(f"Media upload failed for {linkedin_account_id}: {e}. Posting without media.") + + # Backward compatibility: single image_url + elif image_url: try: media_urn = await self._upload_image(access_token, linkedin_user_id, image_url) if media_urn: payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE" payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [ - { - "status": "READY", - "media": media_urn - } + {"status": "READY", "media": media_urn} ] except Exception as e: logger.warning(f"Image upload failed for {linkedin_account_id}: {e}. Posting without image.") @@ -213,6 +252,79 @@ class LinkedInService: logger.error(f"Image upload error: {e}") return None + async def _upload_video( + self, + access_token: str, + linkedin_user_id: str, + video_url: str + ) -> Optional[str]: + """ + Upload video to LinkedIn for use in post. + + Returns media URN if successful, None otherwise. + """ + try: + # Step 1: Register upload with VIDEO recipe + register_payload = { + "registerUploadRequest": { + "recipes": ["urn:li:digitalmediaRecipe:feedshare-video"], # VIDEO recipe + "owner": f"urn:li:person:{linkedin_user_id}", + "serviceRelationships": [ + { + "relationshipType": "OWNER", + "identifier": "urn:li:userGeneratedContent" + } + ] + } + } + + async with httpx.AsyncClient(timeout=60.0) as client: # Longer timeout for videos + register_response = await client.post( + f"{self.BASE_URL}/assets?action=registerUpload", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + }, + json=register_payload + ) + + if register_response.status_code != 200: + logger.error(f"Video register failed: {register_response.status_code}") + return None + + register_data = register_response.json() + upload_url = register_data["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"] + asset_urn = register_data["value"]["asset"] + + # Step 2: Download video from Supabase + video_response = await client.get(video_url) + if video_response.status_code != 200: + logger.error(f"Failed to download video from {video_url}") + return None + + video_data = video_response.content + + # Step 3: Upload to LinkedIn + upload_response = await client.put( + upload_url, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/octet-stream" + }, + content=video_data + ) + + if upload_response.status_code in [200, 201]: + logger.info(f"Video uploaded successfully: {asset_urn}") + return asset_urn + else: + logger.error(f"Video upload failed: {upload_response.status_code}") + return None + + except Exception as e: + logger.error(f"Video upload error: {e}") + return None + async def refresh_access_token(self, linkedin_account_id: UUID) -> bool: """ Attempt to refresh the access token using refresh token. diff --git a/src/services/scheduler_service.py b/src/services/scheduler_service.py index f737e01..013346e 100644 --- a/src/services/scheduler_service.py +++ b/src/services/scheduler_service.py @@ -111,11 +111,20 @@ class SchedulerService: # Token refresh failed -> Fall back to email raise Exception("Token expired and refresh failed") - # Post to LinkedIn + # Post to LinkedIn with media items + # Convert media_items to dict if needed + media_items_list = None + if post.media_items: + media_items_list = [ + item.dict() if hasattr(item, 'dict') else item + for item in post.media_items + ] + result = await linkedin_service.post_to_linkedin( linkedin_account_id=linkedin_account.id, text=post.post_content, - image_url=post.image_url + media_items=media_items_list, + image_url=post.image_url # Backward compatibility fallback ) # Update post as published with LinkedIn metadata @@ -178,6 +187,19 @@ class SchedulerService: display_name = profile.display_name or "dort" + # Generate media HTML + media_html = "" + if post.media_items and len(post.media_items) > 0: + media_items = [item.dict() if hasattr(item, 'dict') else item for item in post.media_items] + for item in sorted(media_items, key=lambda x: x.get('order', 0)): + if item.get('type') == 'image': + media_html += f'Post-Bild' + elif item.get('type') == 'video': + media_html += f'' + elif post.image_url: + # Backward compatibility + media_html = f'Post-Bild' + html_content = f"""

Dein LinkedIn Post ist bereit! 🚀

@@ -191,7 +213,7 @@ class SchedulerService:
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
- {f'Post-Bild' if post.image_url else ''} + {media_html}

Nächste Schritte:

@@ -229,6 +251,19 @@ class SchedulerService: display_name = profile.display_name or "dort" linkedin_url = result.get('url', 'https://www.linkedin.com/feed/') + # Generate media HTML + media_html = "" + if post.media_items and len(post.media_items) > 0: + media_items = [item.dict() if hasattr(item, 'dict') else item for item in post.media_items] + for item in sorted(media_items, key=lambda x: x.get('order', 0)): + if item.get('type') == 'image': + media_html += f'Post-Bild' + elif item.get('type') == 'video': + media_html += f'' + elif post.image_url: + # Backward compatibility + media_html = f'Post-Bild' + html_content = f"""

Post erfolgreich veröffentlicht! 🎉

@@ -242,7 +277,7 @@ class SchedulerService:
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
- {f'Post-Bild' if post.image_url else ''} + {media_html}

diff --git a/src/services/storage_service.py b/src/services/storage_service.py index b14dd41..a1465d4 100644 --- a/src/services/storage_service.py +++ b/src/services/storage_service.py @@ -7,8 +7,13 @@ from loguru import logger from src.config import settings -ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} -MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB +ALLOWED_CONTENT_TYPES = { + "image/jpeg", "image/png", "image/gif", "image/webp", + "video/mp4", "video/webm", "video/quicktime" +} +MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB +MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB +MAX_FILE_SIZE = MAX_IMAGE_SIZE # Deprecated: for backward compatibility BUCKET_NAME = "post-images" CONTENT_TYPE_EXTENSIONS = { @@ -16,6 +21,9 @@ CONTENT_TYPE_EXTENSIONS = { "image/png": "png", "image/gif": "gif", "image/webp": "webp", + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", } @@ -49,23 +57,41 @@ class StorageService: raise self._bucket_ensured = True - async def upload_image( + async def upload_media( self, file_content: bytes, content_type: str, user_id: str, ) -> str: - """Upload an image to Supabase Storage. + """Upload an image or video to Supabase Storage. - Returns the public URL of the uploaded image. + Args: + file_content: File bytes + content_type: MIME type (e.g., 'image/jpeg', 'video/mp4') + user_id: User ID for folder organization + + Returns: + Public URL of the uploaded media. + + Raises: + ValueError: If content type is not allowed or file is too large. """ if content_type not in ALLOWED_CONTENT_TYPES: - raise ValueError(f"Unzulässiger Dateityp: {content_type}. Erlaubt: JPEG, PNG, GIF, WebP") + raise ValueError( + f"Unzulässiger Dateityp: {content_type}. " + f"Erlaubt: JPEG, PNG, GIF, WebP, MP4, WebM, MOV" + ) - if len(file_content) > MAX_FILE_SIZE: - raise ValueError(f"Datei zu groß (max. {MAX_FILE_SIZE // 1024 // 1024} MB)") + # Check size based on type + is_video = content_type.startswith("video/") + max_size = MAX_VIDEO_SIZE if is_video else MAX_IMAGE_SIZE - ext = CONTENT_TYPE_EXTENSIONS[content_type] + if len(file_content) > max_size: + max_mb = max_size // 1024 // 1024 + media_type = "Video" if is_video else "Bild" + raise ValueError(f"{media_type} zu groß (max. {max_mb} MB)") + + ext = CONTENT_TYPE_EXTENSIONS.get(content_type, "bin") file_name = f"{user_id}/{uuid.uuid4()}.{ext}" def _upload(): @@ -79,9 +105,24 @@ class StorageService: await asyncio.to_thread(_upload) public_url = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/{file_name}" - logger.info(f"Uploaded image: {file_name}") + media_type = "video" if is_video else "image" + logger.info(f"Uploaded {media_type}: {file_name}") return public_url + async def upload_image( + self, + file_content: bytes, + content_type: str, + user_id: str, + ) -> str: + """DEPRECATED: Upload an image to Supabase Storage. + + Use upload_media() instead. This method is kept for backward compatibility. + + Returns the public URL of the uploaded image. + """ + return await self.upload_media(file_content, content_type, user_id) + async def delete_image(self, image_url: str) -> None: """Delete an image from Supabase Storage by its public URL.""" prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/" diff --git a/src/web/templates/user/company_manage_create.html b/src/web/templates/user/company_manage_create.html index ba28b18..69c1c4c 100644 --- a/src/web/templates/user/company_manage_create.html +++ b/src/web/templates/user/company_manage_create.html @@ -232,28 +232,29 @@ oder 'Meine Meinung dazu ist...'"

- - -
+
+
+ + LinkedIn Vorschau +
+
+ + +
+
+
{{ employee_name[:2] | upper if employee_name else 'UN' }}
{{ post.post_content }}
- {% if post.image_url %} - Post image - {% else %} - - {% endif %} + + +
+ {% if post.media_items and post.media_items | length > 0 %} +
+ {% for item in post.media_items %} + {% if item.type == 'image' %} + Post media + {% elif item.type == 'video' %} + + {% endif %} + {% endfor %} +
+ {% elif post.image_url %} + + Post image + {% endif %} +
+
@@ -177,40 +225,56 @@
- +

- Bild + Medien ({{ post.media_items | length if post.media_items else 0 }}/3)

-
- - -

Bild hierher ziehen oder durchsuchen

-

JPEG, PNG, GIF, WebP - max. 5 MB

+ + +
+ {% if post.media_items %} + {% for item in post.media_items %} +
+ {% if item.type == 'image' %} + Media {{ loop.index }} + {% elif item.type == 'video' %} + + {% endif %} + + + + + +
+ {{ loop.index }} +
+
+ {% endfor %} + {% endif %}
- @@ -256,6 +320,25 @@ function copyToClipboard() { }); } +// Preview Mode Toggle (Desktop/Mobile) +function setPreviewMode(mode) { + const preview = document.querySelector('.linkedin-preview'); + const desktopBtn = document.getElementById('desktopPreviewBtn'); + const mobileBtn = document.getElementById('mobilePreviewBtn'); + + if (mode === 'desktop') { + preview.classList.remove('mobile-view'); + preview.classList.add('desktop-view'); + desktopBtn.classList.add('active'); + mobileBtn.classList.remove('active'); + } else if (mode === 'mobile') { + preview.classList.remove('desktop-view'); + preview.classList.add('mobile-view'); + mobileBtn.classList.add('active'); + desktopBtn.classList.remove('active'); + } +} + async function updateStatus(newStatus) { try { const formData = new FormData(); @@ -294,13 +377,38 @@ function showToast(message, type = 'info') { }, 3000); } -async function handleImageUpload(file) { +// ==================== MULTI-MEDIA UPLOAD ==================== + +let currentMediaItems = {{ media_items_dict | tojson | safe }}; + +async function handleMediaUpload(file) { if (!file) return; - const uploadZone = document.getElementById('imageUploadZone'); - const progressEl = document.getElementById('imageUploadProgress'); - const progressBar = document.getElementById('imageProgressBar'); - const previewSection = document.getElementById('imagePreviewSection'); + const uploadZone = document.getElementById('mediaUploadZone'); + const progressEl = document.getElementById('mediaUploadProgress'); + const progressBar = document.getElementById('mediaProgressBar'); + const warningEl = document.getElementById('mediaTypeWarning'); + + if (currentMediaItems.length >= 3) { + showToast('Maximal 3 Medien erlaubt', 'error'); + return; + } + + const isVideo = file.type.startsWith('video/'); + const hasImages = currentMediaItems.some(item => item.type === 'image'); + const hasVideos = currentMediaItems.some(item => item.type === 'video'); + + if ((isVideo && hasImages) || (!isVideo && hasVideos)) { + warningEl.classList.remove('hidden'); + showToast('Kann nicht Bilder und Videos mischen', 'error'); + setTimeout(() => warningEl.classList.add('hidden'), 3000); + return; + } + + if (isVideo && currentMediaItems.length > 0) { + showToast('Nur 1 Video pro Post erlaubt', 'error'); + return; + } uploadZone.classList.add('hidden'); progressEl.classList.remove('hidden'); @@ -308,10 +416,10 @@ async function handleImageUpload(file) { try { const formData = new FormData(); - formData.append('image', file); + formData.append('file', file); progressBar.style.width = '60%'; - const response = await fetch(`/api/posts/${POST_ID}/image`, { + const response = await fetch(`/api/posts/${POST_ID}/media`, { method: 'POST', body: formData }); @@ -325,69 +433,265 @@ async function handleImageUpload(file) { const result = await response.json(); progressBar.style.width = '100%'; - - document.getElementById('linkedinPostImage').src = result.image_url; - document.getElementById('linkedinPostImage').style.display = 'block'; - document.getElementById('sidebarImagePreview').src = result.image_url; + currentMediaItems = result.media_items || []; setTimeout(() => { progressEl.classList.add('hidden'); - previewSection.classList.remove('hidden'); + refreshMediaGrid(); + refreshLinkedInPreview(); }, 300); - showToast('Bild erfolgreich hochgeladen!', 'success'); + const mediaType = isVideo ? 'Video' : 'Bild'; + showToast(`${mediaType} erfolgreich hochgeladen!`, 'success'); } catch (error) { - console.error('Image upload error:', error); + console.error('Media upload error:', error); showToast('Fehler: ' + error.message, 'error'); progressEl.classList.add('hidden'); uploadZone.classList.remove('hidden'); } } -async function removeImage() { - const btn = document.getElementById('removeImageBtn'); - const originalHTML = btn.innerHTML; - btn.innerHTML = '
'; - btn.disabled = true; +async function deleteMedia(index) { + if (!confirm('Media wirklich löschen?')) return; try { - const response = await fetch(`/api/posts/${POST_ID}/image`, { method: 'DELETE' }); - if (!response.ok) throw new Error('Löschen fehlgeschlagen'); + const response = await fetch(`/api/posts/${POST_ID}/media/${index}`, { method: 'DELETE' }); - document.getElementById('linkedinPostImage').style.display = 'none'; - document.getElementById('imagePreviewSection').classList.add('hidden'); - document.getElementById('imageUploadZone').classList.remove('hidden'); - showToast('Bild entfernt.', 'success'); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Löschen fehlgeschlagen'); + } + + const result = await response.json(); + currentMediaItems = result.media_items || []; + + refreshMediaGrid(); + refreshLinkedInPreview(); + showToast('Media entfernt', 'success'); } catch (error) { - console.error('Image delete error:', error); + console.error('Media delete error:', error); showToast('Fehler: ' + error.message, 'error'); - } finally { - btn.innerHTML = originalHTML; - btn.disabled = false; } } -document.addEventListener('DOMContentLoaded', () => { - const uploadZone = document.getElementById('imageUploadZone'); - const fileInput = document.getElementById('imageFileInput'); - const replaceInput = document.getElementById('imageReplaceInput'); +function refreshMediaGrid() { + const grid = document.getElementById('mediaGrid'); + const count = document.getElementById('mediaCount'); + const uploadZone = document.getElementById('mediaUploadZone'); - if (uploadZone) { - uploadZone.addEventListener('click', () => fileInput.click()); - fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); }); - if (replaceInput) { - replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); }); - } - uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); }); - uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); }); - uploadZone.addEventListener('drop', (e) => { - e.preventDefault(); - uploadZone.classList.remove('dragover'); - if (e.dataTransfer.files[0]) handleImageUpload(e.dataTransfer.files[0]); - }); + count.textContent = currentMediaItems.length; + + if (currentMediaItems.length === 0) { + grid.classList.add('hidden'); + uploadZone.classList.remove('hidden'); + return; } + + grid.classList.remove('hidden'); + grid.style.gridTemplateColumns = `repeat(${currentMediaItems.length}, 1fr)`; + + grid.innerHTML = currentMediaItems.map((item, i) => { + const isDragged = draggedItemData && item.url === draggedItemData.url; + const ghostClass = isDragged ? 'is-ghost' : ''; + const ghostStyle = isDragged ? 'opacity: 0.3;' : ''; + + return ` +
+
+ ${item.type === 'image' + ? `Media ${i+1}` + : `` + } +
+ +
${i+1}
+
+ `; + }).join(''); + + initDragAndDrop(); + + if (currentMediaItems.length >= 3) { + uploadZone.classList.add('hidden'); + } else { + uploadZone.classList.remove('hidden'); + } +} + +function refreshLinkedInPreview() { + const linkedinMediaSection = document.getElementById('linkedinMediaSection'); + if (!linkedinMediaSection) return; + + if (currentMediaItems.length === 0) { + linkedinMediaSection.classList.add('hidden'); + return; + } + + linkedinMediaSection.classList.remove('hidden'); + const gridClass = currentMediaItems.length === 1 ? 'grid-cols-1' : + currentMediaItems.length === 2 ? 'grid-cols-2' : 'grid-cols-3'; + + linkedinMediaSection.innerHTML = ` +
+ ${currentMediaItems.map(item => + item.type === 'image' + ? `` + : `` + ).join('')} +
+ `; +} + +// Drag-and-Drop Reorder (Ultra Smooth with Ghost) +let draggedArrayIndex = null; +let originalDraggedIndex = null; // Track the original starting position +let draggedElement = null; +let lastTargetIndex = null; +let isSaving = false; +let draggedItemData = null; + +function initDragAndDrop() { + const mediaGrid = document.getElementById('mediaGrid'); + if (!mediaGrid) return; + + const mediaItems = mediaGrid.querySelectorAll('.media-item'); + + mediaItems.forEach((item, idx) => { + item.addEventListener('dragstart', (e) => { + draggedArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex); + originalDraggedIndex = draggedArrayIndex; // Save the original position + draggedElement = e.currentTarget; + lastTargetIndex = draggedArrayIndex; + draggedItemData = currentMediaItems[draggedArrayIndex]; + + setTimeout(() => { + e.currentTarget.style.opacity = '0.3'; + }, 0); + + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setDragImage(e.currentTarget, e.currentTarget.offsetWidth / 2, e.currentTarget.offsetHeight / 2); + }); + + item.addEventListener('dragend', (e) => { + const allItems = mediaGrid.querySelectorAll('.media-item'); + allItems.forEach(i => { + i.style.opacity = '1'; + i.classList.remove('is-ghost'); + }); + + console.log('🎯 Dragend:', {originalDraggedIndex, lastTargetIndex}); + + if (lastTargetIndex !== null && lastTargetIndex !== originalDraggedIndex && !isSaving) { + console.log('💾 Triggering save...'); + saveReorderInBackground(); + } else { + console.log('⏭️ Skip save'); + } + + draggedArrayIndex = null; + originalDraggedIndex = null; + draggedElement = null; + lastTargetIndex = null; + draggedItemData = null; + }); + + item.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + if (draggedArrayIndex === null) return; + + const targetArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex); + + if (targetArrayIndex !== lastTargetIndex && draggedArrayIndex !== targetArrayIndex) { + lastTargetIndex = targetArrayIndex; + + const newMediaItems = [...currentMediaItems]; + const [movedItem] = newMediaItems.splice(draggedArrayIndex, 1); + newMediaItems.splice(targetArrayIndex, 0, movedItem); + + currentMediaItems = newMediaItems; + draggedArrayIndex = targetArrayIndex; + + refreshMediaGrid(); + refreshLinkedInPreview(); + } + }); + + item.addEventListener('drop', (e) => { + e.preventDefault(); + }); + }); +} + +async function saveReorderInBackground() { + if (isSaving) return; + isSaving = true; + + const orderArray = currentMediaItems.map((item, i) => item.order); + + try { + const response = await fetch(`/api/posts/${POST_ID}/media/reorder`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({order: orderArray}) + }); + + if (!response.ok) throw new Error('Speichern fehlgeschlagen'); + + const result = await response.json(); + currentMediaItems = result.media_items || []; + + console.log('✓ Reihenfolge im Hintergrund gespeichert'); + + } catch (error) { + console.error('Background save error:', error); + showToast('Fehler beim Speichern - bitte Seite neu laden', 'error'); + } finally { + isSaving = false; + } +} + +function initMediaUpload() { + const uploadZone = document.getElementById('mediaUploadZone'); + const fileInput = document.getElementById('mediaFileInput'); + + if (!uploadZone) return; + + uploadZone.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', (e) => { + if (e.target.files[0]) handleMediaUpload(e.target.files[0]); + e.target.value = ''; + }); + + uploadZone.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadZone.classList.add('dragover'); + }); + uploadZone.addEventListener('dragleave', () => { + uploadZone.classList.remove('dragover'); + }); + uploadZone.addEventListener('drop', (e) => { + e.preventDefault(); + uploadZone.classList.remove('dragover'); + if (e.dataTransfer.files[0]) handleMediaUpload(e.dataTransfer.files[0]); + }); + + initDragAndDrop(); +} + +document.addEventListener('DOMContentLoaded', () => { + initMediaUpload(); }); {% endblock %} diff --git a/src/web/templates/user/create_post.html b/src/web/templates/user/create_post.html index ddd09a9..2ffacb9 100644 --- a/src/web/templates/user/create_post.html +++ b/src/web/templates/user/create_post.html @@ -227,28 +227,29 @@ oder 'Meine Meinung dazu ist...'"
- -