multiple media upload and smartphone preview

This commit is contained in:
2026-02-11 23:21:43 +01:00
parent 64bf300677
commit 4bbaad0b4e
10 changed files with 1842 additions and 364 deletions

View File

@@ -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}]';

View File

@@ -1,5 +1,5 @@
"""Pydantic models for database entities.""" """Pydantic models for database entities."""
from datetime import datetime, date from datetime import datetime, date, timezone
from enum import Enum from enum import Enum
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from uuid import UUID from uuid import UUID
@@ -312,6 +312,16 @@ class ApiUsageLog(DBModel):
created_at: Optional[datetime] = None 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): class GeneratedPost(DBModel):
"""Generated post model.""" """Generated post model."""
id: Optional[UUID] = None id: Optional[UUID] = None
@@ -327,7 +337,9 @@ class GeneratedPost(DBModel):
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None published_at: Optional[datetime] = None
post_type_id: Optional[UUID] = 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 image_url: Optional[str] = None
# Scheduling fields # Scheduling fields
scheduled_at: Optional[datetime] = None 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 for additional info (e.g., LinkedIn post URL, auto-posting status)
metadata: Optional[Dict[str, Any]] = None 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 ==================== # ==================== LICENSE KEY MODELS ====================

View File

@@ -35,7 +35,8 @@ class LinkedInService:
self, self,
linkedin_account_id: UUID, linkedin_account_id: UUID,
text: str, text: str,
image_url: Optional[str] = None image_url: Optional[str] = None,
media_items: Optional[list] = None
) -> Dict: ) -> Dict:
""" """
Post content to LinkedIn using UGC Posts API. Post content to LinkedIn using UGC Posts API.
@@ -43,7 +44,9 @@ class LinkedInService:
Args: Args:
linkedin_account_id: ID of the linkedin_accounts record linkedin_account_id: ID of the linkedin_accounts record
text: Post text content 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: Returns:
Dict with 'url' key containing the LinkedIn post URL Dict with 'url' key containing the LinkedIn post URL
@@ -80,17 +83,53 @@ class LinkedInService:
} }
} }
# Handle image upload if provided # Handle media upload (new multi-media support)
if image_url: 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: try:
media_urn = await self._upload_image(access_token, linkedin_user_id, image_url) media_urn = await self._upload_image(access_token, linkedin_user_id, image_url)
if media_urn: if media_urn:
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE" payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE"
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [ payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
{ {"status": "READY", "media": media_urn}
"status": "READY",
"media": media_urn
}
] ]
except Exception as e: except Exception as e:
logger.warning(f"Image upload failed for {linkedin_account_id}: {e}. Posting without image.") 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}") logger.error(f"Image upload error: {e}")
return None 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: async def refresh_access_token(self, linkedin_account_id: UUID) -> bool:
""" """
Attempt to refresh the access token using refresh token. Attempt to refresh the access token using refresh token.

View File

@@ -111,11 +111,20 @@ class SchedulerService:
# Token refresh failed -> Fall back to email # Token refresh failed -> Fall back to email
raise Exception("Token expired and refresh failed") 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( result = await linkedin_service.post_to_linkedin(
linkedin_account_id=linkedin_account.id, linkedin_account_id=linkedin_account.id,
text=post.post_content, 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 # Update post as published with LinkedIn metadata
@@ -178,6 +187,19 @@ class SchedulerService:
display_name = profile.display_name or "dort" 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'<img src="{item["url"]}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />'
elif item.get('type') == 'video':
media_html += f'<video src="{item["url"]}" controls style="width: 100%; max-height: 400px; border-radius: 8px; margin-top: 15px;"></video>'
elif post.image_url:
# Backward compatibility
media_html = f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />'
html_content = f""" html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #0a66c2;">Dein LinkedIn Post ist bereit! 🚀</h2> <h2 style="color: #0a66c2;">Dein LinkedIn Post ist bereit! 🚀</h2>
@@ -191,7 +213,7 @@ class SchedulerService:
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;"> <div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''} {post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
</div> </div>
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''} {media_html}
</div> </div>
<p><strong>Nächste Schritte:</strong></p> <p><strong>Nächste Schritte:</strong></p>
@@ -229,6 +251,19 @@ class SchedulerService:
display_name = profile.display_name or "dort" display_name = profile.display_name or "dort"
linkedin_url = result.get('url', 'https://www.linkedin.com/feed/') 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'<img src="{item["url"]}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />'
elif item.get('type') == 'video':
media_html += f'<video src="{item["url"]}" controls style="width: 100%; max-height: 400px; border-radius: 8px; margin-top: 15px;"></video>'
elif post.image_url:
# Backward compatibility
media_html = f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />'
html_content = f""" html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #0a66c2;">Post erfolgreich veröffentlicht! 🎉</h2> <h2 style="color: #0a66c2;">Post erfolgreich veröffentlicht! 🎉</h2>
@@ -242,7 +277,7 @@ class SchedulerService:
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;"> <div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''} {post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
</div> </div>
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''} {media_html}
</div> </div>
<p> <p>

View File

@@ -7,8 +7,13 @@ from loguru import logger
from src.config import settings from src.config import settings
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} ALLOWED_CONTENT_TYPES = {
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB "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" BUCKET_NAME = "post-images"
CONTENT_TYPE_EXTENSIONS = { CONTENT_TYPE_EXTENSIONS = {
@@ -16,6 +21,9 @@ CONTENT_TYPE_EXTENSIONS = {
"image/png": "png", "image/png": "png",
"image/gif": "gif", "image/gif": "gif",
"image/webp": "webp", "image/webp": "webp",
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
} }
@@ -49,23 +57,41 @@ class StorageService:
raise raise
self._bucket_ensured = True self._bucket_ensured = True
async def upload_image( async def upload_media(
self, self,
file_content: bytes, file_content: bytes,
content_type: str, content_type: str,
user_id: str, user_id: str,
) -> 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: 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: # Check size based on type
raise ValueError(f"Datei zu groß (max. {MAX_FILE_SIZE // 1024 // 1024} MB)") 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}" file_name = f"{user_id}/{uuid.uuid4()}.{ext}"
def _upload(): def _upload():
@@ -79,9 +105,24 @@ class StorageService:
await asyncio.to_thread(_upload) await asyncio.to_thread(_upload)
public_url = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/{file_name}" 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 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: async def delete_image(self, image_url: str) -> None:
"""Delete an image from Supabase Storage by its public URL.""" """Delete an image from Supabase Storage by its public URL."""
prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/" prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/"

View File

@@ -232,28 +232,29 @@ oder 'Meine Meinung dazu ist...'"
<div class="card-bg rounded-xl border p-8"> <div class="card-bg rounded-xl border p-8">
<div id="resultHeader" class="flex items-center justify-between mb-6"></div> <div id="resultHeader" class="flex items-center justify-between mb-6"></div>
<div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6"></div> <div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6"></div>
<!-- Image Upload Area --> <!-- Media Upload Area (Multi-Media Support) -->
<div id="resultImageArea" class="mb-6 hidden"> <div id="resultMediaArea" class="mb-6 hidden">
<div id="resultImageUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors"> <h4 class="text-white font-semibold mb-3">Medien (<span id="resultMediaCount">0</span>/3)</h4>
<input type="file" id="resultImageInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
<!-- Media Grid -->
<div id="resultMediaGrid" class="grid gap-3 mb-3 hidden"></div>
<!-- Upload Zone -->
<div id="resultMediaUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
<input type="file" id="resultMediaInput" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime" class="hidden">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p> <p class="text-sm text-gray-400">Bild oder Video hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p> <p class="text-xs text-gray-500 mt-1">Bilder: max. 5 MB | Videos: max. 50 MB</p>
<p id="resultMediaTypeWarning" class="text-xs text-yellow-400 mt-2 hidden">⚠️ Kann nicht Bilder und Videos mischen</p>
</div> </div>
<div id="resultImageProgress" class="hidden mt-2">
<!-- Upload Progress -->
<div id="resultMediaProgress" class="hidden mt-2">
<div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;"> <div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;">
<div id="resultImageProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div> <div id="resultMediaProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div>
</div> </div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p> <p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div> </div>
<div id="resultImagePreview" class="hidden mt-3">
<img id="resultImageImg" src="" alt="Post-Bild" class="rounded-lg w-full max-h-64 object-cover mb-2">
<div class="flex gap-2 justify-center">
<button onclick="document.getElementById('resultImageReplaceInput').click()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg text-sm transition-colors">Ersetzen</button>
<button onclick="removeResultImage()" id="removeResultImageBtn" class="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition-colors">Entfernen</button>
</div>
<input type="file" id="resultImageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div> </div>
<div id="resultActions" class="flex gap-3 flex-wrap justify-center"></div> <div id="resultActions" class="flex gap-3 flex-wrap justify-center"></div>
</div> </div>
@@ -718,12 +719,11 @@ function showFinalResult(result) {
// Store post ID for image upload and show image area // Store post ID for image upload and show image area
window.currentResultPostId = result.post_id; window.currentResultPostId = result.post_id;
const imageArea = document.getElementById('resultImageArea'); const mediaArea = document.getElementById('resultMediaArea');
imageArea.classList.remove('hidden'); mediaArea.classList.remove('hidden');
document.getElementById('resultImageUploadZone').classList.remove('hidden'); resultMediaItems = []; // Reset media items
document.getElementById('resultImagePreview').classList.add('hidden'); refreshResultMediaGrid();
document.getElementById('resultImageProgress').classList.add('hidden'); initResultMediaUpload();
initResultImageUpload();
resultActions.innerHTML = ` resultActions.innerHTML = `
<button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2"> <button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2">
@@ -871,15 +871,39 @@ function showToast(message, type = 'info') {
} }
// ==================== RESULT IMAGE UPLOAD ==================== // ==================== RESULT IMAGE UPLOAD ====================
let resultImageInitialized = false; // ==================== RESULT MEDIA UPLOAD (Multi-Media) ====================
async function handleResultImageUpload(file) { let resultMediaInitialized = false;
let resultMediaItems = [];
async function handleResultMediaUpload(file) {
if (!file || !window.currentResultPostId) return; if (!file || !window.currentResultPostId) return;
const uploadZone = document.getElementById('resultImageUploadZone'); const uploadZone = document.getElementById('resultMediaUploadZone');
const progressEl = document.getElementById('resultImageProgress'); const progressEl = document.getElementById('resultMediaProgress');
const progressBar = document.getElementById('resultImageProgressBar'); const progressBar = document.getElementById('resultMediaProgressBar');
const previewEl = document.getElementById('resultImagePreview'); const warningEl = document.getElementById('resultMediaTypeWarning');
if (resultMediaItems.length >= 3) {
showToast('Maximal 3 Medien erlaubt', 'error');
return;
}
const isVideo = file.type.startsWith('video/');
const hasImages = resultMediaItems.some(item => item.type === 'image');
const hasVideos = resultMediaItems.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 && resultMediaItems.length > 0) {
showToast('Nur 1 Video pro Post erlaubt', 'error');
return;
}
uploadZone.classList.add('hidden'); uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden'); progressEl.classList.remove('hidden');
@@ -887,10 +911,10 @@ async function handleResultImageUpload(file) {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('file', file);
progressBar.style.width = '60%'; progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { const response = await fetch(`/api/posts/${window.currentResultPostId}/media`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -904,69 +928,216 @@ async function handleResultImageUpload(file) {
const result = await response.json(); const result = await response.json();
progressBar.style.width = '100%'; progressBar.style.width = '100%';
resultMediaItems = result.media_items || [];
document.getElementById('resultImageImg').src = result.image_url;
setTimeout(() => { setTimeout(() => {
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
previewEl.classList.remove('hidden'); refreshResultMediaGrid();
}, 300); }, 300);
showToast('Bild erfolgreich hochgeladen!', 'success'); const mediaType = isVideo ? 'Video' : 'Bild';
showToast(`${mediaType} erfolgreich hochgeladen!`, 'success');
} catch (error) { } catch (error) {
console.error('Image upload error:', error); console.error('Media upload error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden'); uploadZone.classList.remove('hidden');
} }
} }
async function removeResultImage() { async function deleteResultMedia(index) {
if (!window.currentResultPostId) return; if (!window.currentResultPostId) return;
const btn = document.getElementById('removeResultImageBtn'); if (!confirm('Media wirklich löschen?')) return;
const originalHTML = btn.innerHTML;
btn.innerHTML = 'Wird entfernt...';
btn.disabled = true;
try { try {
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { method: 'DELETE' }); const response = await fetch(`/api/posts/${window.currentResultPostId}/media/${index}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('resultImagePreview').classList.add('hidden'); if (!response.ok) {
document.getElementById('resultImageUploadZone').classList.remove('hidden'); const err = await response.json();
showToast('Bild entfernt.', 'success'); throw new Error(err.detail || 'Löschen fehlgeschlagen');
}
const result = await response.json();
resultMediaItems = result.media_items || [];
refreshResultMediaGrid();
showToast('Media entfernt', 'success');
} catch (error) { } catch (error) {
console.error('Image delete error:', error); console.error('Media delete error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
} }
} }
function initResultImageUpload() { // Drag-and-drop state for result media
if (resultImageInitialized) return; let resultDraggedItemData = null;
resultImageInitialized = true; let resultDraggedArrayIndex = null;
let resultOriginalDraggedIndex = null;
const uploadZone = document.getElementById('resultImageUploadZone'); function refreshResultMediaGrid() {
const fileInput = document.getElementById('resultImageInput'); const grid = document.getElementById('resultMediaGrid');
const replaceInput = document.getElementById('resultImageReplaceInput'); const count = document.getElementById('resultMediaCount');
const uploadZone = document.getElementById('resultMediaUploadZone');
count.textContent = resultMediaItems.length;
if (resultMediaItems.length === 0) {
grid.classList.add('hidden');
uploadZone.classList.remove('hidden');
return;
}
grid.classList.remove('hidden');
grid.style.gridTemplateColumns = `repeat(${resultMediaItems.length}, 1fr)`;
grid.innerHTML = resultMediaItems.map((item, i) => {
const isDragged = resultDraggedItemData && item.url === resultDraggedItemData.url;
const ghostStyle = isDragged ? 'opacity: 0.3;' : '';
return `
<div class="media-item relative group rounded-lg overflow-hidden"
data-array-index="${i}"
draggable="true"
style="transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); ${ghostStyle} cursor: move;">
${item.type === 'image'
? `<img src="${item.url}" alt="Media ${i+1}" class="w-full h-48 object-cover pointer-events-none">`
: `<video src="${item.url}" class="w-full h-48 object-cover pointer-events-none" controls></video>`
}
<button onclick="deleteResultMedia(${item.order})"
class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700 z-10">
<svg class="w-4 h-4 text-white pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white pointer-events-none">${i+1}</div>
</div>
`;
}).join('');
if (resultMediaItems.length >= 3) {
uploadZone.classList.add('hidden');
} else {
uploadZone.classList.remove('hidden');
}
// Initialize drag-and-drop for result media
initResultMediaDragAndDrop();
}
function initResultMediaDragAndDrop() {
const grid = document.getElementById('resultMediaGrid');
if (!grid) return;
const items = grid.querySelectorAll('.media-item');
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
resultDraggedArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex);
resultOriginalDraggedIndex = resultDraggedArrayIndex; // Save original position
resultDraggedItemData = resultMediaItems[resultDraggedArrayIndex];
setTimeout(() => {
e.currentTarget.style.opacity = '0.3';
}, 0);
e.dataTransfer.effectAllowed = 'move';
const rect = e.currentTarget.getBoundingClientRect();
e.dataTransfer.setDragImage(e.currentTarget, rect.width / 2, rect.height / 2);
});
item.addEventListener('dragend', async (e) => {
e.currentTarget.style.opacity = '1';
// Save reorder in background only if position changed
console.log('🎯 Result dragend:', {resultOriginalDraggedIndex, resultDraggedArrayIndex});
if (resultOriginalDraggedIndex !== null && resultDraggedArrayIndex !== resultOriginalDraggedIndex) {
console.log('💾 Triggering result save...');
await saveResultReorderInBackground();
} else {
console.log('⏭️ Skip result save');
}
resultDraggedItemData = null;
resultDraggedArrayIndex = null;
resultOriginalDraggedIndex = null;
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
if (resultDraggedArrayIndex === null) return;
const targetArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex);
if (targetArrayIndex === resultDraggedArrayIndex) return;
// INSTANT visual reorder
const newMediaItems = [...resultMediaItems];
const [movedItem] = newMediaItems.splice(resultDraggedArrayIndex, 1);
newMediaItems.splice(targetArrayIndex, 0, movedItem);
resultMediaItems = newMediaItems;
resultDraggedArrayIndex = targetArrayIndex;
refreshResultMediaGrid();
});
item.addEventListener('drop', (e) => {
e.preventDefault();
});
});
}
async function saveResultReorderInBackground() {
if (!window.currentResultPostId) return;
try {
// Extract original order values from current arrangement
const newOrder = resultMediaItems.map(item => item.order);
const response = await fetch(`/api/posts/${window.currentResultPostId}/media/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: newOrder })
});
if (response.ok) {
const result = await response.json();
resultMediaItems = result.media_items || [];
}
} catch (error) {
console.error('Background reorder failed:', error);
}
}
function initResultMediaUpload() {
if (resultMediaInitialized) return;
resultMediaInitialized = true;
const uploadZone = document.getElementById('resultMediaUploadZone');
const fileInput = document.getElementById('resultMediaInput');
if (!uploadZone) return; if (!uploadZone) return;
uploadZone.addEventListener('click', () => fileInput.click()); uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); }); fileInput.addEventListener('change', (e) => {
if (replaceInput) { if (e.target.files[0]) handleResultMediaUpload(e.target.files[0]);
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); }); e.target.value = '';
} });
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); }); uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
});
uploadZone.addEventListener('drop', (e) => { uploadZone.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
if (e.dataTransfer.files[0]) handleResultImageUpload(e.dataTransfer.files[0]); if (e.dataTransfer.files[0]) handleResultMediaUpload(e.dataTransfer.files[0]);
}); });
} }

View File

@@ -52,6 +52,24 @@
.image-upload-progress-bar { height: 100%; background: #ffc700; border-radius: 2px; transition: width 0.3s; } .image-upload-progress-bar { height: 100%; background: #ffc700; border-radius: 2px; transition: width 0.3s; }
.loading-spinner { border: 2px solid rgba(255, 199, 0, 0.2); border-top-color: #ffc700; border-radius: 50%; width: 20px; height: 20px; animation: spin 0.8s linear infinite; } .loading-spinner { border: 2px solid rgba(255, 199, 0, 0.2); border-top-color: #ffc700; border-radius: 50%; width: 20px; height: 20px; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* Preview Mode Toggle */
.preview-mode-btn { padding: 6px 12px; font-size: 12px; font-weight: 500; border-radius: 6px; color: #9ca3af; background: transparent; border: none; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
.preview-mode-btn.active { background: rgba(255, 199, 0, 0.2); color: #ffc700; }
.preview-mode-btn:hover:not(.active) { color: #d1d5db; }
/* Desktop View */
.linkedin-preview.desktop-view { max-width: 100%; margin: 0; }
/* Mobile View - smartphone mockup */
.linkedin-preview.mobile-view { max-width: 375px; margin: 0 auto; border: 12px solid #1f1f1f; border-radius: 36px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1); position: relative; }
.linkedin-preview.mobile-view::before { content: ''; position: absolute; top: -8px; left: 50%; transform: translateX(-50%); width: 60px; height: 4px; background: #2a2a2a; border-radius: 2px; }
.linkedin-preview.mobile-view .linkedin-header { padding: 16px; }
.linkedin-preview.mobile-view .linkedin-avatar { width: 40px; height: 40px; font-size: 16px; }
.linkedin-preview.mobile-view .linkedin-name { font-size: 13px; }
.linkedin-preview.mobile-view .linkedin-headline { font-size: 11px; }
.linkedin-preview.mobile-view .linkedin-timestamp { font-size: 11px; }
.linkedin-preview.mobile-view .linkedin-content { padding: 0 16px 16px; font-size: 13px; line-height: 1.4; }
.linkedin-preview.mobile-view .linkedin-engagement { padding: 12px 16px; font-size: 11px; }
.linkedin-preview.mobile-view .linkedin-action-btn { font-size: 13px; padding: 14px 8px; gap: 4px; }
.linkedin-preview.mobile-view .linkedin-action-btn svg { width: 18px; height: 18px; }
</style> </style>
{% endblock %} {% endblock %}
@@ -106,7 +124,23 @@
</div> </div>
<!-- LinkedIn Preview --> <!-- LinkedIn Preview -->
<div class="linkedin-preview shadow-lg"> <div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2 text-sm text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
LinkedIn Vorschau
</div>
<div class="flex items-center gap-1 bg-brand-bg rounded-lg p-1">
<button onclick="setPreviewMode('desktop')" id="desktopPreviewBtn" class="preview-mode-btn active">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Desktop
</button>
<button onclick="setPreviewMode('mobile')" id="mobilePreviewBtn" class="preview-mode-btn">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
Smartphone
</button>
</div>
</div>
<div class="linkedin-preview shadow-lg desktop-view">
<div class="linkedin-header"> <div class="linkedin-header">
<div class="linkedin-avatar">{{ employee_name[:2] | upper if employee_name else 'UN' }}</div> <div class="linkedin-avatar">{{ employee_name[:2] | upper if employee_name else 'UN' }}</div>
<div class="linkedin-user-info"> <div class="linkedin-user-info">
@@ -122,11 +156,25 @@
</div> </div>
</div> </div>
<div class="linkedin-content">{{ post.post_content }}</div> <div class="linkedin-content">{{ post.post_content }}</div>
{% if post.image_url %}
<img id="linkedinPostImage" src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image"> <!-- LinkedIn Media Display -->
{% else %} <div id="linkedinMediaSection" class="{% if not post.media_items or post.media_items | length == 0 %}{% if not post.image_url %}hidden{% endif %}{% endif %}">
<img id="linkedinPostImage" src="" alt="Post image" class="linkedin-post-image" style="display: none;"> {% if post.media_items and post.media_items | length > 0 %}
<div class="grid gap-1" style="grid-template-columns: repeat({{ post.media_items | length if post.media_items | length <= 3 else 3 }}, 1fr);">
{% for item in post.media_items %}
{% if item.type == 'image' %}
<img src="{{ item.url }}" alt="Post media" class="w-full h-48 object-cover">
{% elif item.type == 'video' %}
<video src="{{ item.url }}" class="w-full h-48 object-cover" controls></video>
{% endif %} {% endif %}
{% endfor %}
</div>
{% elif post.image_url %}
<!-- Backward compatibility: single image -->
<img src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image">
{% endif %}
</div>
<div class="linkedin-engagement"> <div class="linkedin-engagement">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2">
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/> <path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
@@ -177,40 +225,56 @@
</div> </div>
</div> </div>
<!-- Image Upload --> <!-- Media Upload Section (Multi-Media Support) -->
<div class="section-card rounded-xl p-6"> <div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4 flex items-center gap-2"> <h3 class="font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Bild Medien (<span id="mediaCount">{{ post.media_items | length if post.media_items else 0 }}</span>/3)
</h3> </h3>
<div id="imageUploadZone" class="image-upload-zone {% if post.image_url %}hidden{% endif %}">
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp"> <!-- Media Grid (shown when media exists) -->
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg> <div id="mediaGrid" class="grid gap-3 mb-3 {% if not post.media_items or post.media_items | length == 0 %}hidden{% endif %}" style="grid-template-columns: repeat({{ post.media_items | length if post.media_items and post.media_items | length <= 3 else 1 }}, 1fr);">
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight cursor-pointer">durchsuchen</span></p> {% if post.media_items %}
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p> {% for item in post.media_items %}
<div class="media-item relative group rounded-lg overflow-hidden" data-index="{{ item.order if item.order is defined else loop.index0 }}" draggable="true" style="cursor: grab;">
{% if item.type == 'image' %}
<img src="{{ item.url }}" alt="Media {{ loop.index }}" class="w-full h-48 object-cover">
{% elif item.type == 'video' %}
<video src="{{ item.url }}" class="w-full h-48 object-cover" controls></video>
{% endif %}
<!-- Delete button -->
<button onclick="deleteMedia({{ item.order if item.order is defined else loop.index0 }})" class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Order badge -->
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white">
{{ loop.index }}
</div> </div>
<div id="imageUploadProgress" class="hidden"> </div>
{% endfor %}
{% endif %}
</div>
<!-- Upload Zone (shown when < 3 media items) -->
<div id="mediaUploadZone" class="{% if post.media_items and post.media_items | length >= 3 %}hidden{% endif %} border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
<input type="file" id="mediaFileInput" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime" class="hidden">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<p class="text-sm text-gray-400">Bild oder Video hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">Bilder: max. 5 MB | Videos: max. 50 MB</p>
<p id="mediaTypeWarning" class="text-xs text-yellow-400 mt-2 hidden">⚠️ Kann nicht Bilder und Videos mischen</p>
</div>
<!-- Upload Progress -->
<div id="mediaUploadProgress" class="hidden">
<div class="image-upload-progress"> <div class="image-upload-progress">
<div id="imageProgressBar" class="image-upload-progress-bar" style="width: 0%"></div> <div id="mediaProgressBar" class="image-upload-progress-bar" style="width: 0%"></div>
</div> </div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p> <p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div> </div>
<div id="imagePreviewSection" class="{% if not post.image_url %}hidden{% endif %}">
<div class="image-preview-container mb-3">
<img id="sidebarImagePreview" src="{{ post.image_url or '' }}" alt="Post-Bild">
</div>
<div class="flex gap-2">
<button onclick="document.getElementById('imageReplaceInput').click()" class="flex-1 px-3 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors text-sm flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Ersetzen
</button>
<button onclick="removeImage()" id="removeImageBtn" class="flex-1 px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg transition-colors text-sm flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Entfernen
</button>
</div>
<input type="file" id="imageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div> </div>
<!-- Post Info --> <!-- Post Info -->
@@ -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) { async function updateStatus(newStatus) {
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -294,13 +377,38 @@ function showToast(message, type = 'info') {
}, 3000); }, 3000);
} }
async function handleImageUpload(file) { // ==================== MULTI-MEDIA UPLOAD ====================
let currentMediaItems = {{ media_items_dict | tojson | safe }};
async function handleMediaUpload(file) {
if (!file) return; if (!file) return;
const uploadZone = document.getElementById('imageUploadZone'); const uploadZone = document.getElementById('mediaUploadZone');
const progressEl = document.getElementById('imageUploadProgress'); const progressEl = document.getElementById('mediaUploadProgress');
const progressBar = document.getElementById('imageProgressBar'); const progressBar = document.getElementById('mediaProgressBar');
const previewSection = document.getElementById('imagePreviewSection'); 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'); uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden'); progressEl.classList.remove('hidden');
@@ -308,10 +416,10 @@ async function handleImageUpload(file) {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('file', file);
progressBar.style.width = '60%'; progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${POST_ID}/image`, { const response = await fetch(`/api/posts/${POST_ID}/media`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -325,69 +433,265 @@ async function handleImageUpload(file) {
const result = await response.json(); const result = await response.json();
progressBar.style.width = '100%'; progressBar.style.width = '100%';
currentMediaItems = result.media_items || [];
document.getElementById('linkedinPostImage').src = result.image_url;
document.getElementById('linkedinPostImage').style.display = 'block';
document.getElementById('sidebarImagePreview').src = result.image_url;
setTimeout(() => { setTimeout(() => {
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
previewSection.classList.remove('hidden'); refreshMediaGrid();
refreshLinkedInPreview();
}, 300); }, 300);
showToast('Bild erfolgreich hochgeladen!', 'success'); const mediaType = isVideo ? 'Video' : 'Bild';
showToast(`${mediaType} erfolgreich hochgeladen!`, 'success');
} catch (error) { } catch (error) {
console.error('Image upload error:', error); console.error('Media upload error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden'); uploadZone.classList.remove('hidden');
} }
} }
async function removeImage() { async function deleteMedia(index) {
const btn = document.getElementById('removeImageBtn'); if (!confirm('Media wirklich löschen?')) return;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;"></div>';
btn.disabled = true;
try { try {
const response = await fetch(`/api/posts/${POST_ID}/image`, { method: 'DELETE' }); const response = await fetch(`/api/posts/${POST_ID}/media/${index}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('linkedinPostImage').style.display = 'none'; if (!response.ok) {
document.getElementById('imagePreviewSection').classList.add('hidden'); const err = await response.json();
document.getElementById('imageUploadZone').classList.remove('hidden'); throw new Error(err.detail || 'Löschen fehlgeschlagen');
showToast('Bild entfernt.', 'success'); }
const result = await response.json();
currentMediaItems = result.media_items || [];
refreshMediaGrid();
refreshLinkedInPreview();
showToast('Media entfernt', 'success');
} catch (error) { } catch (error) {
console.error('Image delete error:', error); console.error('Media delete error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
} }
} }
document.addEventListener('DOMContentLoaded', () => { function refreshMediaGrid() {
const uploadZone = document.getElementById('imageUploadZone'); const grid = document.getElementById('mediaGrid');
const fileInput = document.getElementById('imageFileInput'); const count = document.getElementById('mediaCount');
const replaceInput = document.getElementById('imageReplaceInput'); const uploadZone = document.getElementById('mediaUploadZone');
if (uploadZone) { count.textContent = currentMediaItems.length;
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); }); if (currentMediaItems.length === 0) {
if (replaceInput) { grid.classList.add('hidden');
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); }); uploadZone.classList.remove('hidden');
return;
} }
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); }); 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 `
<div class="media-item relative group rounded-lg overflow-hidden border-2 border-transparent hover:border-brand-highlight ${ghostClass}"
data-array-index="${i}"
draggable="true"
style="cursor: grab; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); ${ghostStyle}">
<div class="media-content">
${item.type === 'image'
? `<img src="${item.url}" alt="Media ${i+1}" class="w-full h-48 object-cover pointer-events-none">`
: `<video src="${item.url}" class="w-full h-48 object-cover pointer-events-none" controls></video>`
}
</div>
<button onclick="deleteMedia(${item.order})"
class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700 z-10">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white pointer-events-none">${i+1}</div>
</div>
`;
}).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 = `
<div class="grid ${gridClass} gap-1">
${currentMediaItems.map(item =>
item.type === 'image'
? `<img src="${item.url}" class="w-full h-48 object-cover">`
: `<video src="${item.url}" class="w-full h-48 object-cover" controls></video>`
).join('')}
</div>
`;
}
// 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) => { uploadZone.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
uploadZone.classList.remove('dragover'); uploadZone.classList.remove('dragover');
if (e.dataTransfer.files[0]) handleImageUpload(e.dataTransfer.files[0]); if (e.dataTransfer.files[0]) handleMediaUpload(e.dataTransfer.files[0]);
}); });
}
initDragAndDrop();
}
document.addEventListener('DOMContentLoaded', () => {
initMediaUpload();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -227,28 +227,29 @@ oder 'Meine Meinung dazu ist...'"
<div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6"> <div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6">
<!-- Final post will be inserted here --> <!-- Final post will be inserted here -->
</div> </div>
<!-- Image Upload Area --> <!-- Media Upload Area (Multi-Media Support) -->
<div id="resultImageArea" class="mb-6 hidden"> <div id="resultMediaArea" class="mb-6 hidden">
<div id="resultImageUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors"> <h4 class="text-white font-semibold mb-3">Medien (<span id="resultMediaCount">0</span>/3)</h4>
<input type="file" id="resultImageInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
<!-- Media Grid -->
<div id="resultMediaGrid" class="grid gap-3 mb-3 hidden"></div>
<!-- Upload Zone -->
<div id="resultMediaUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
<input type="file" id="resultMediaInput" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime" class="hidden">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p> <p class="text-sm text-gray-400">Bild oder Video hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p> <p class="text-xs text-gray-500 mt-1">Bilder: max. 5 MB | Videos: max. 50 MB</p>
<p id="resultMediaTypeWarning" class="text-xs text-yellow-400 mt-2 hidden">⚠️ Kann nicht Bilder und Videos mischen</p>
</div> </div>
<div id="resultImageProgress" class="hidden mt-2">
<!-- Upload Progress -->
<div id="resultMediaProgress" class="hidden mt-2">
<div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;"> <div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;">
<div id="resultImageProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div> <div id="resultMediaProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div>
</div> </div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p> <p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div> </div>
<div id="resultImagePreview" class="hidden mt-3">
<img id="resultImageImg" src="" alt="Post-Bild" class="rounded-lg w-full max-h-64 object-cover mb-2">
<div class="flex gap-2 justify-center">
<button onclick="document.getElementById('resultImageReplaceInput').click()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg text-sm transition-colors">Ersetzen</button>
<button onclick="removeResultImage()" id="removeResultImageBtn" class="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition-colors">Entfernen</button>
</div>
<input type="file" id="resultImageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div> </div>
<div id="resultActions" class="flex gap-3 flex-wrap justify-center"> <div id="resultActions" class="flex gap-3 flex-wrap justify-center">
<!-- Action buttons will be inserted here --> <!-- Action buttons will be inserted here -->
@@ -753,15 +754,14 @@ function showFinalResult(result) {
postResult.innerHTML = `<pre class="whitespace-pre-wrap text-gray-200 font-sans text-lg leading-relaxed">${result.final_post}</pre>`; postResult.innerHTML = `<pre class="whitespace-pre-wrap text-gray-200 font-sans text-lg leading-relaxed">${result.final_post}</pre>`;
// Store post ID for image upload and show image area // Store post ID for media upload and show media area
window.currentResultPostId = result.post_id; window.currentResultPostId = result.post_id;
const imageArea = document.getElementById('resultImageArea'); const mediaArea = document.getElementById('resultMediaArea');
imageArea.classList.remove('hidden'); mediaArea.classList.remove('hidden');
// Reset image UI // Reset media UI
document.getElementById('resultImageUploadZone').classList.remove('hidden'); resultMediaItems = [];
document.getElementById('resultImagePreview').classList.add('hidden'); refreshResultMediaGrid();
document.getElementById('resultImageProgress').classList.add('hidden'); initResultMediaUpload();
initResultImageUpload();
resultActions.innerHTML = ` resultActions.innerHTML = `
<button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2"> <button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2">
@@ -918,15 +918,39 @@ function showToast(message, type = 'info') {
} }
// ==================== RESULT IMAGE UPLOAD ==================== // ==================== RESULT IMAGE UPLOAD ====================
let resultImageInitialized = false; // ==================== RESULT MEDIA UPLOAD (Multi-Media) ====================
async function handleResultImageUpload(file) { let resultMediaInitialized = false;
let resultMediaItems = [];
async function handleResultMediaUpload(file) {
if (!file || !window.currentResultPostId) return; if (!file || !window.currentResultPostId) return;
const uploadZone = document.getElementById('resultImageUploadZone'); const uploadZone = document.getElementById('resultMediaUploadZone');
const progressEl = document.getElementById('resultImageProgress'); const progressEl = document.getElementById('resultMediaProgress');
const progressBar = document.getElementById('resultImageProgressBar'); const progressBar = document.getElementById('resultMediaProgressBar');
const previewEl = document.getElementById('resultImagePreview'); const warningEl = document.getElementById('resultMediaTypeWarning');
if (resultMediaItems.length >= 3) {
showToast('Maximal 3 Medien erlaubt', 'error');
return;
}
const isVideo = file.type.startsWith('video/');
const hasImages = resultMediaItems.some(item => item.type === 'image');
const hasVideos = resultMediaItems.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 && resultMediaItems.length > 0) {
showToast('Nur 1 Video pro Post erlaubt', 'error');
return;
}
uploadZone.classList.add('hidden'); uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden'); progressEl.classList.remove('hidden');
@@ -934,10 +958,10 @@ async function handleResultImageUpload(file) {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('file', file);
progressBar.style.width = '60%'; progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { const response = await fetch(`/api/posts/${window.currentResultPostId}/media`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -951,69 +975,216 @@ async function handleResultImageUpload(file) {
const result = await response.json(); const result = await response.json();
progressBar.style.width = '100%'; progressBar.style.width = '100%';
resultMediaItems = result.media_items || [];
document.getElementById('resultImageImg').src = result.image_url;
setTimeout(() => { setTimeout(() => {
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
previewEl.classList.remove('hidden'); refreshResultMediaGrid();
}, 300); }, 300);
showToast('Bild erfolgreich hochgeladen!', 'success'); const mediaType = isVideo ? 'Video' : 'Bild';
showToast(`${mediaType} erfolgreich hochgeladen!`, 'success');
} catch (error) { } catch (error) {
console.error('Image upload error:', error); console.error('Media upload error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden'); uploadZone.classList.remove('hidden');
} }
} }
async function removeResultImage() { async function deleteResultMedia(index) {
if (!window.currentResultPostId) return; if (!window.currentResultPostId) return;
const btn = document.getElementById('removeResultImageBtn'); if (!confirm('Media wirklich löschen?')) return;
const originalHTML = btn.innerHTML;
btn.innerHTML = 'Wird entfernt...';
btn.disabled = true;
try { try {
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { method: 'DELETE' }); const response = await fetch(`/api/posts/${window.currentResultPostId}/media/${index}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('resultImagePreview').classList.add('hidden'); if (!response.ok) {
document.getElementById('resultImageUploadZone').classList.remove('hidden'); const err = await response.json();
showToast('Bild entfernt.', 'success'); throw new Error(err.detail || 'Löschen fehlgeschlagen');
}
const result = await response.json();
resultMediaItems = result.media_items || [];
refreshResultMediaGrid();
showToast('Media entfernt', 'success');
} catch (error) { } catch (error) {
console.error('Image delete error:', error); console.error('Media delete error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
} }
} }
function initResultImageUpload() { // Drag-and-drop state for result media
if (resultImageInitialized) return; let resultDraggedItemData = null;
resultImageInitialized = true; let resultDraggedArrayIndex = null;
let resultOriginalDraggedIndex = null;
const uploadZone = document.getElementById('resultImageUploadZone'); function refreshResultMediaGrid() {
const fileInput = document.getElementById('resultImageInput'); const grid = document.getElementById('resultMediaGrid');
const replaceInput = document.getElementById('resultImageReplaceInput'); const count = document.getElementById('resultMediaCount');
const uploadZone = document.getElementById('resultMediaUploadZone');
count.textContent = resultMediaItems.length;
if (resultMediaItems.length === 0) {
grid.classList.add('hidden');
uploadZone.classList.remove('hidden');
return;
}
grid.classList.remove('hidden');
grid.style.gridTemplateColumns = `repeat(${resultMediaItems.length}, 1fr)`;
grid.innerHTML = resultMediaItems.map((item, i) => {
const isDragged = resultDraggedItemData && item.url === resultDraggedItemData.url;
const ghostStyle = isDragged ? 'opacity: 0.3;' : '';
return `
<div class="media-item relative group rounded-lg overflow-hidden"
data-array-index="${i}"
draggable="true"
style="transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); ${ghostStyle} cursor: move;">
${item.type === 'image'
? `<img src="${item.url}" alt="Media ${i+1}" class="w-full h-48 object-cover pointer-events-none">`
: `<video src="${item.url}" class="w-full h-48 object-cover pointer-events-none" controls></video>`
}
<button onclick="deleteResultMedia(${item.order})"
class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700 z-10">
<svg class="w-4 h-4 text-white pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white pointer-events-none">${i+1}</div>
</div>
`;
}).join('');
if (resultMediaItems.length >= 3) {
uploadZone.classList.add('hidden');
} else {
uploadZone.classList.remove('hidden');
}
// Initialize drag-and-drop for result media
initResultMediaDragAndDrop();
}
function initResultMediaDragAndDrop() {
const grid = document.getElementById('resultMediaGrid');
if (!grid) return;
const items = grid.querySelectorAll('.media-item');
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
resultDraggedArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex);
resultOriginalDraggedIndex = resultDraggedArrayIndex; // Save original position
resultDraggedItemData = resultMediaItems[resultDraggedArrayIndex];
setTimeout(() => {
e.currentTarget.style.opacity = '0.3';
}, 0);
e.dataTransfer.effectAllowed = 'move';
const rect = e.currentTarget.getBoundingClientRect();
e.dataTransfer.setDragImage(e.currentTarget, rect.width / 2, rect.height / 2);
});
item.addEventListener('dragend', async (e) => {
e.currentTarget.style.opacity = '1';
// Save reorder in background only if position changed
console.log('🎯 Result dragend:', {resultOriginalDraggedIndex, resultDraggedArrayIndex});
if (resultOriginalDraggedIndex !== null && resultDraggedArrayIndex !== resultOriginalDraggedIndex) {
console.log('💾 Triggering result save...');
await saveResultReorderInBackground();
} else {
console.log('⏭️ Skip result save');
}
resultDraggedItemData = null;
resultDraggedArrayIndex = null;
resultOriginalDraggedIndex = null;
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
if (resultDraggedArrayIndex === null) return;
const targetArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex);
if (targetArrayIndex === resultDraggedArrayIndex) return;
// INSTANT visual reorder
const newMediaItems = [...resultMediaItems];
const [movedItem] = newMediaItems.splice(resultDraggedArrayIndex, 1);
newMediaItems.splice(targetArrayIndex, 0, movedItem);
resultMediaItems = newMediaItems;
resultDraggedArrayIndex = targetArrayIndex;
refreshResultMediaGrid();
});
item.addEventListener('drop', (e) => {
e.preventDefault();
});
});
}
async function saveResultReorderInBackground() {
if (!window.currentResultPostId) return;
try {
// Extract original order values from current arrangement
const newOrder = resultMediaItems.map(item => item.order);
const response = await fetch(`/api/posts/${window.currentResultPostId}/media/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: newOrder })
});
if (response.ok) {
const result = await response.json();
resultMediaItems = result.media_items || [];
}
} catch (error) {
console.error('Background reorder failed:', error);
}
}
function initResultMediaUpload() {
if (resultMediaInitialized) return;
resultMediaInitialized = true;
const uploadZone = document.getElementById('resultMediaUploadZone');
const fileInput = document.getElementById('resultMediaInput');
if (!uploadZone) return; if (!uploadZone) return;
uploadZone.addEventListener('click', () => fileInput.click()); uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); }); fileInput.addEventListener('change', (e) => {
if (replaceInput) { if (e.target.files[0]) handleResultMediaUpload(e.target.files[0]);
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); }); e.target.value = '';
} });
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); }); uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
});
uploadZone.addEventListener('drop', (e) => { uploadZone.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
if (e.dataTransfer.files[0]) handleResultImageUpload(e.dataTransfer.files[0]); if (e.dataTransfer.files[0]) handleResultMediaUpload(e.dataTransfer.files[0]);
}); });
} }

View File

@@ -202,6 +202,92 @@
background: rgba(255, 199, 0, 0.2); background: rgba(255, 199, 0, 0.2);
color: #ffc700; color: #ffc700;
} }
/* Preview Mode Toggle */
.preview-mode-btn {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
color: #9ca3af;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.preview-mode-btn.active {
background: rgba(255, 199, 0, 0.2);
color: #ffc700;
}
.preview-mode-btn:hover:not(.active) {
color: #d1d5db;
}
/* Desktop View - full width */
.linkedin-preview.desktop-view {
max-width: 100%;
margin: 0;
}
/* Mobile View - smartphone mockup */
.linkedin-preview.mobile-view {
max-width: 375px;
margin: 0 auto;
border: 12px solid #1f1f1f;
border-radius: 36px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
position: relative;
}
.linkedin-preview.mobile-view::before {
content: '';
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 4px;
background: #2a2a2a;
border-radius: 2px;
}
.linkedin-preview.mobile-view .linkedin-header {
padding: 16px;
}
.linkedin-preview.mobile-view .linkedin-avatar {
width: 40px;
height: 40px;
font-size: 16px;
}
.linkedin-preview.mobile-view .linkedin-name {
font-size: 13px;
}
.linkedin-preview.mobile-view .linkedin-headline {
font-size: 11px;
}
.linkedin-preview.mobile-view .linkedin-timestamp {
font-size: 11px;
}
.linkedin-preview.mobile-view .linkedin-content {
padding: 0 16px 16px;
font-size: 13px;
line-height: 1.4;
}
.linkedin-preview.mobile-view .linkedin-see-more {
padding: 0 16px 16px;
font-size: 13px;
}
.linkedin-preview.mobile-view .linkedin-engagement {
padding: 12px 16px;
font-size: 11px;
}
.linkedin-preview.mobile-view .linkedin-action-btn {
font-size: 13px;
padding: 14px 8px;
gap: 4px;
}
.linkedin-preview.mobile-view .linkedin-action-btn svg {
width: 18px;
height: 18px;
}
.view-toggle-btn:hover:not(.active) { .view-toggle-btn:hover:not(.active) {
color: #e5e7eb; color: #e5e7eb;
} }
@@ -419,7 +505,23 @@
</div> </div>
<!-- LinkedIn Preview View --> <!-- LinkedIn Preview View -->
<div id="linkedinPreview" class="linkedin-preview shadow-lg"> <div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2 text-sm text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
LinkedIn Vorschau
</div>
<div class="flex items-center gap-1 bg-brand-bg rounded-lg p-1">
<button onclick="setPreviewMode('desktop')" id="desktopPreviewBtn" class="preview-mode-btn active">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Desktop
</button>
<button onclick="setPreviewMode('mobile')" id="mobilePreviewBtn" class="preview-mode-btn">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
Smartphone
</button>
</div>
</div>
<div id="linkedinPreview" class="linkedin-preview shadow-lg desktop-view">
<div class="linkedin-header"> <div class="linkedin-header">
<div class="linkedin-avatar"> <div class="linkedin-avatar">
{% if profile_picture_url %} {% if profile_picture_url %}
@@ -447,11 +549,24 @@
</div> </div>
<div id="linkedinContent" class="linkedin-content collapsed">{{ post.post_content }}</div> <div id="linkedinContent" class="linkedin-content collapsed">{{ post.post_content }}</div>
<div id="seeMoreBtn" class="linkedin-see-more" onclick="toggleContent()">...mehr anzeigen</div> <div id="seeMoreBtn" class="linkedin-see-more" onclick="toggleContent()">...mehr anzeigen</div>
{% if post.image_url %}
<img id="linkedinPostImage" src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image"> <!-- LinkedIn Media Display -->
{% else %} <div id="linkedinMediaSection" class="{% if not post.media_items or post.media_items | length == 0 %}{% if not post.image_url %}hidden{% endif %}{% endif %}">
<img id="linkedinPostImage" src="" alt="Post image" class="linkedin-post-image" style="display: none;"> {% if post.media_items and post.media_items | length > 0 %}
<div class="grid gap-1" style="grid-template-columns: repeat({{ post.media_items | length if post.media_items | length <= 3 else 3 }}, 1fr);">
{% for item in post.media_items %}
{% if item.type == 'image' %}
<img src="{{ item.url }}" alt="Post media" class="w-full h-48 object-cover">
{% elif item.type == 'video' %}
<video src="{{ item.url }}" class="w-full h-48 object-cover" controls></video>
{% endif %} {% endif %}
{% endfor %}
</div>
{% elif post.image_url %}
<!-- Backward compatibility: single image -->
<img src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image">
{% endif %}
</div>
<div class="linkedin-engagement"> <div class="linkedin-engagement">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2">
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/> <path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
@@ -572,46 +687,56 @@
</div> </div>
</div> </div>
<!-- Image Upload Section --> <!-- Media Upload Section (Multi-Media Support) -->
<div class="section-card rounded-xl p-6 mb-6"> <div class="section-card rounded-xl p-6 mb-6">
<h3 class="font-semibold text-white mb-4 flex items-center gap-2"> <h3 class="font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Bild Medien (<span id="mediaCount">{{ post.media_items | length if post.media_items else 0 }}</span>/3)
</h3> </h3>
<!-- Upload Zone (shown when no image) --> <!-- Media Grid (shown when media exists) -->
<div id="imageUploadZone" class="image-upload-zone {% if post.image_url %}hidden{% endif %}"> <div id="mediaGrid" class="grid gap-3 mb-3 {% if not post.media_items or post.media_items | length == 0 %}hidden{% endif %}" style="grid-template-columns: repeat({{ post.media_items | length if post.media_items and post.media_items | length <= 3 else 1 }}, 1fr);">
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp"> {% if post.media_items %}
{% for item in post.media_items %}
<div class="media-item relative group rounded-lg overflow-hidden" data-index="{{ item.order if item.order is defined else loop.index0 }}" draggable="true" style="cursor: grab;">
{% if item.type == 'image' %}
<img src="{{ item.url }}" alt="Media {{ loop.index }}" class="w-full h-48 object-cover">
{% elif item.type == 'video' %}
<video src="{{ item.url }}" class="w-full h-48 object-cover" controls></video>
{% endif %}
<!-- Delete button -->
<button onclick="deleteMedia({{ item.order if item.order is defined else loop.index0 }})" class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Order badge -->
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white">
{{ loop.index }}
</div>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Upload Zone (shown when < 3 media items) -->
<div id="mediaUploadZone" class="{% if post.media_items and post.media_items | length >= 3 %}hidden{% endif %} border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
<input type="file" id="mediaFileInput" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime" class="hidden">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg> <svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight cursor-pointer">durchsuchen</span></p> <p class="text-sm text-gray-400">Bild oder Video hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p> <p class="text-xs text-gray-500 mt-1">Bilder: max. 5 MB | Videos: max. 50 MB</p>
<p id="mediaTypeWarning" class="text-xs text-yellow-400 mt-2 hidden">⚠️ Kann nicht Bilder und Videos mischen</p>
</div> </div>
<!-- Upload Progress --> <!-- Upload Progress -->
<div id="imageUploadProgress" class="hidden"> <div id="mediaUploadProgress" class="hidden">
<div class="image-upload-progress"> <div class="image-upload-progress">
<div id="imageProgressBar" class="image-upload-progress-bar" style="width: 0%"></div> <div id="mediaProgressBar" class="image-upload-progress-bar" style="width: 0%"></div>
</div> </div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p> <p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div> </div>
<!-- Image Preview (shown when image exists) -->
<div id="imagePreviewSection" class="{% if not post.image_url %}hidden{% endif %}">
<div class="image-preview-container mb-3">
<img id="sidebarImagePreview" src="{{ post.image_url or '' }}" alt="Post-Bild">
</div>
<div class="flex gap-2">
<button onclick="document.getElementById('imageReplaceInput').click()" class="flex-1 px-3 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors text-sm flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Ersetzen
</button>
<button onclick="removeImage()" id="removeImageBtn" class="flex-1 px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg transition-colors text-sm flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Entfernen
</button>
</div>
<input type="file" id="imageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div> </div>
<!-- Bottom Section: Score, Feedback, Actions --> <!-- Bottom Section: Score, Feedback, Actions -->
@@ -884,6 +1009,25 @@ function setView(view) {
} }
} }
// Preview Mode Toggle (Desktop/Mobile)
function setPreviewMode(mode) {
const preview = document.getElementById('linkedinPreview');
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');
}
}
// Edit functions // Edit functions
function updateCharCount() { function updateCharCount() {
const textarea = document.getElementById('editTextarea'); const textarea = document.getElementById('editTextarea');
@@ -1289,15 +1433,41 @@ function changeVersion(delta) {
document.getElementById('nextVersion').disabled = currentVersion === totalVersions - 1; document.getElementById('nextVersion').disabled = currentVersion === totalVersions - 1;
} }
// ==================== IMAGE UPLOAD ==================== // ==================== MULTI-MEDIA UPLOAD ====================
async function handleImageUpload(file) { let currentMediaItems = {{ media_items_dict | tojson | safe }};
async function handleMediaUpload(file) {
if (!file) return; if (!file) return;
const uploadZone = document.getElementById('imageUploadZone'); const uploadZone = document.getElementById('mediaUploadZone');
const progressEl = document.getElementById('imageUploadProgress'); const progressEl = document.getElementById('mediaUploadProgress');
const progressBar = document.getElementById('imageProgressBar'); const progressBar = document.getElementById('mediaProgressBar');
const previewSection = document.getElementById('imagePreviewSection'); const warningEl = document.getElementById('mediaTypeWarning');
// Validate max 3
if (currentMediaItems.length >= 3) {
showToast('Maximal 3 Medien erlaubt', 'error');
return;
}
// Validate no mixing
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;
}
// Only 1 video allowed
if (isVideo && currentMediaItems.length > 0) {
showToast('Nur 1 Video pro Post erlaubt', 'error');
return;
}
// Show progress // Show progress
uploadZone.classList.add('hidden'); uploadZone.classList.add('hidden');
@@ -1306,11 +1476,11 @@ async function handleImageUpload(file) {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('file', file);
progressBar.style.width = '60%'; progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${postId}/image`, { const response = await fetch(`/api/posts/${postId}/media`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -1325,64 +1495,319 @@ async function handleImageUpload(file) {
const result = await response.json(); const result = await response.json();
progressBar.style.width = '100%'; progressBar.style.width = '100%';
// Update UI // Update current media items
const linkedinImg = document.getElementById('linkedinPostImage'); currentMediaItems = result.media_items || [];
const sidebarImg = document.getElementById('sidebarImagePreview');
linkedinImg.src = result.image_url;
linkedinImg.style.display = 'block';
sidebarImg.src = result.image_url;
setTimeout(() => { setTimeout(() => {
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
previewSection.classList.remove('hidden'); refreshMediaGrid();
refreshLinkedInPreview();
}, 300); }, 300);
showToast('Bild erfolgreich hochgeladen!', 'success'); const mediaType = isVideo ? 'Video' : 'Bild';
showToast(`${mediaType} erfolgreich hochgeladen!`, 'success');
} catch (error) { } catch (error) {
console.error('Image upload error:', error); console.error('Media upload error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden'); progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden'); uploadZone.classList.remove('hidden');
} }
} }
async function removeImage() { async function deleteMedia(index) {
const btn = document.getElementById('removeImageBtn'); if (!confirm('Media wirklich löschen?')) return;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;"></div>';
btn.disabled = true;
try { try {
const response = await fetch(`/api/posts/${postId}/image`, { const response = await fetch(`/api/posts/${postId}/media/${index}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Löschen fehlgeschlagen'); const err = await response.json();
throw new Error(err.detail || 'Löschen fehlgeschlagen');
} }
// Update UI const result = await response.json();
document.getElementById('linkedinPostImage').style.display = 'none'; currentMediaItems = result.media_items || [];
document.getElementById('imagePreviewSection').classList.add('hidden');
document.getElementById('imageUploadZone').classList.remove('hidden');
showToast('Bild entfernt.', 'success'); refreshMediaGrid();
refreshLinkedInPreview();
showToast('Media entfernt', 'success');
} catch (error) { } catch (error) {
console.error('Image delete error:', error); console.error('Media delete error:', error);
showToast('Fehler: ' + error.message, 'error'); showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
} }
} }
function initImageUpload() { function refreshMediaGrid() {
const uploadZone = document.getElementById('imageUploadZone'); const grid = document.getElementById('mediaGrid');
const fileInput = document.getElementById('imageFileInput'); const count = document.getElementById('mediaCount');
const replaceInput = document.getElementById('imageReplaceInput'); const uploadZone = document.getElementById('mediaUploadZone');
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) => `
<div class="media-item relative group rounded-lg overflow-hidden border-2 border-transparent hover:border-brand-highlight"
data-array-index="${i}"
draggable="true"
style="cursor: grab; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);">
<div class="media-content">
${item.type === 'image'
? `<img src="${item.url}" alt="Media ${i+1}" class="w-full h-48 object-cover pointer-events-none">`
: `<video src="${item.url}" class="w-full h-48 object-cover pointer-events-none" controls></video>`
}
</div>
<button onclick="deleteMedia(${item.order})"
class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700 z-10">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white pointer-events-none">${i+1}</div>
</div>
`).join('');
// Re-attach drag handlers after refresh
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 = `
<div class="grid ${gridClass} gap-1">
${currentMediaItems.map(item =>
item.type === 'image'
? `<img src="${item.url}" class="w-full h-48 object-cover">`
: `<video src="${item.url}" class="w-full h-48 object-cover" controls></video>`
).join('')}
</div>
`;
}
// 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) => {
// Dragstart - Element wird gegriffen
item.addEventListener('dragstart', (e) => {
draggedArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex);
originalDraggedIndex = draggedArrayIndex; // Save the original position
draggedElement = e.currentTarget;
lastTargetIndex = draggedArrayIndex;
draggedItemData = currentMediaItems[draggedArrayIndex];
// Smooth fade out des gezogenen Elements
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);
});
// Dragend - Element wird losgelassen
item.addEventListener('dragend', (e) => {
// Entferne alle Ghost-Klassen
const allItems = mediaGrid.querySelectorAll('.media-item');
allItems.forEach(i => {
i.style.opacity = '1';
i.classList.remove('is-ghost');
});
// Wenn sich die Position geändert hat, speichere im Hintergrund
console.log('🎯 Dragend:', {originalDraggedIndex, lastTargetIndex, changed: lastTargetIndex !== null && lastTargetIndex !== originalDraggedIndex});
if (lastTargetIndex !== null && lastTargetIndex !== originalDraggedIndex && !isSaving) {
console.log('💾 Triggering save...');
saveReorderInBackground();
} else {
console.log('⏭️ Skip save:', {lastTargetIndex, originalDraggedIndex, isSaving});
}
draggedArrayIndex = null;
originalDraggedIndex = null;
draggedElement = null;
lastTargetIndex = null;
draggedItemData = null;
});
// Dragover - Element schwebt über anderem Element (LIVE REORDER)
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedArrayIndex === null) return;
const targetArrayIndex = parseInt(e.currentTarget.dataset.arrayIndex);
// Nur neu ordnen wenn sich die Position geändert hat
if (targetArrayIndex !== lastTargetIndex && draggedArrayIndex !== targetArrayIndex) {
lastTargetIndex = targetArrayIndex;
// SOFORTIGES visuelles Update
const newMediaItems = [...currentMediaItems];
const [movedItem] = newMediaItems.splice(draggedArrayIndex, 1);
newMediaItems.splice(targetArrayIndex, 0, movedItem);
currentMediaItems = newMediaItems;
draggedArrayIndex = targetArrayIndex;
// Smooth Re-Render
refreshMediaGridSmooth();
refreshLinkedInPreview();
}
});
// Drop - Element wird losgelassen
item.addEventListener('drop', (e) => {
e.preventDefault();
});
});
}
// Smooth version of refreshMediaGrid that maintains the dragging state
function refreshMediaGridSmooth() {
const grid = document.getElementById('mediaGrid');
const count = document.getElementById('mediaCount');
const uploadZone = document.getElementById('mediaUploadZone');
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 `
<div class="media-item relative group rounded-lg overflow-hidden border-2 border-transparent hover:border-brand-highlight ${ghostClass}"
data-array-index="${i}"
draggable="true"
style="cursor: grab; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); ${ghostStyle}">
<div class="media-content">
${item.type === 'image'
? `<img src="${item.url}" alt="Media ${i+1}" class="w-full h-48 object-cover pointer-events-none">`
: `<video src="${item.url}" class="w-full h-48 object-cover pointer-events-none" controls></video>`
}
</div>
<button onclick="deleteMedia(${item.order})"
class="absolute top-2 right-2 p-2 bg-red-600 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700 z-10">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="absolute bottom-2 left-2 px-2 py-1 bg-black/50 rounded text-xs text-white pointer-events-none">${i+1}</div>
</div>
`;
}).join('');
if (currentMediaItems.length >= 3) {
uploadZone.classList.add('hidden');
} else {
uploadZone.classList.remove('hidden');
}
// Re-attach drag handlers
initDragAndDrop();
}
// Speichert die neue Reihenfolge im Hintergrund
async function saveReorderInBackground() {
if (isSaving) return;
isSaving = true;
// Erstelle Order-Array für Backend
const orderArray = currentMediaItems.map((item, i) => item.order);
console.log('🔍 Sending reorder:', {
currentMediaItems: currentMediaItems.map(item => ({url: item.url.substring(item.url.length-20), order: item.order})),
orderArray: orderArray
});
try {
const response = await fetch(`/api/posts/${postId}/media/reorder`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order: orderArray})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('❌ Reorder failed:', response.status, errorData);
throw new Error('Speichern fehlgeschlagen: ' + (errorData.detail || response.statusText));
}
const result = await response.json();
console.log('✅ Reorder response:', {
received: result.media_items.map(item => ({url: item.url.substring(item.url.length-20), order: item.order}))
});
// Update mit Server-Response (für korrekte order-Werte)
currentMediaItems = result.media_items || [];
// Stilles Update ohne Re-Render (UI ist schon aktuell)
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; if (!uploadZone) return;
@@ -1391,17 +1816,11 @@ function initImageUpload() {
// File selected // File selected
fileInput.addEventListener('change', (e) => { fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) handleImageUpload(e.target.files[0]); if (e.target.files[0]) handleMediaUpload(e.target.files[0]);
e.target.value = ''; // Reset input
}); });
// Replace input // Drag & drop for upload zone
if (replaceInput) {
replaceInput.addEventListener('change', (e) => {
if (e.target.files[0]) handleImageUpload(e.target.files[0]);
});
}
// Drag & drop
uploadZone.addEventListener('dragover', (e) => { uploadZone.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
uploadZone.classList.add('dragover'); uploadZone.classList.add('dragover');
@@ -1412,8 +1831,11 @@ function initImageUpload() {
uploadZone.addEventListener('drop', (e) => { uploadZone.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
uploadZone.classList.remove('dragover'); uploadZone.classList.remove('dragover');
if (e.dataTransfer.files[0]) handleImageUpload(e.dataTransfer.files[0]); if (e.dataTransfer.files[0]) handleMediaUpload(e.dataTransfer.files[0]);
}); });
// Initialize drag-and-drop for reordering
initDragAndDrop();
} }
// Initialize // Initialize
@@ -1430,8 +1852,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize LinkedIn preview // Initialize LinkedIn preview
initLinkedInPreview(); initLinkedInPreview();
// Initialize image upload // Initialize media upload (multi-media support)
initImageUpload(); initMediaUpload();
// Add event listener for textarea character count // Add event listener for textarea character count
const textarea = document.getElementById('editTextarea'); const textarea = document.getElementById('editTextarea');

View File

@@ -1533,6 +1533,14 @@ async def post_detail_page(request: Request, post_id: str):
if post.critic_feedback and len(post.critic_feedback) > 0: if post.critic_feedback and len(post.critic_feedback) > 0:
final_feedback = post.critic_feedback[-1] final_feedback = post.critic_feedback[-1]
# Convert media_items to dicts for JSON serialization in template
media_items_dict = []
if post.media_items:
media_items_dict = [
item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item)
for item in post.media_items
]
return templates.TemplateResponse("post_detail.html", { return templates.TemplateResponse("post_detail.html", {
"request": request, "request": request,
"page": "posts", "page": "posts",
@@ -1544,7 +1552,8 @@ async def post_detail_page(request: Request, post_id: str):
"post_type": post_type, "post_type": post_type,
"post_type_analysis": post_type_analysis, "post_type_analysis": post_type_analysis,
"final_feedback": final_feedback, "final_feedback": final_feedback,
"profile_picture_url": profile_picture_url "profile_picture_url": profile_picture_url,
"media_items_dict": media_items_dict
}) })
except Exception as e: except Exception as e:
logger.error(f"Error loading post detail: {e}") logger.error(f"Error loading post detail: {e}")
@@ -2679,6 +2688,14 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id
profile = await db.get_profile(emp_profile.id) profile = await db.get_profile(emp_profile.id)
# Convert media_items to dicts for JSON serialization in template
media_items_dict = []
if post.media_items:
media_items_dict = [
item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item)
for item in post.media_items
]
return templates.TemplateResponse("company_manage_post_detail.html", { return templates.TemplateResponse("company_manage_post_detail.html", {
"request": request, "request": request,
"page": "manage", "page": "manage",
@@ -2686,7 +2703,8 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id
"employee_id": employee_id, "employee_id": employee_id,
"employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email,
"profile": profile, "profile": profile,
"post": post "post": post,
"media_items_dict": media_items_dict
}) })
@@ -3433,61 +3451,18 @@ async def unschedule_post(request: Request, post_id: str):
@user_router.post("/api/posts/{post_id}/image") @user_router.post("/api/posts/{post_id}/image")
async def upload_post_image(request: Request, post_id: str): async def upload_post_image(request: Request, post_id: str):
"""Upload or replace an image for a post.""" """DEPRECATED: Upload or replace an image for a post.
session = require_user_session(request)
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
try: Use /api/posts/{post_id}/media instead. This endpoint is kept for backward compatibility.
post = await db.get_generated_post(UUID(post_id)) """
if not post: # Delegate to new media upload endpoint
raise HTTPException(status_code=404, detail="Post not found") result = await upload_post_media(request, post_id)
# Check authorization: owner or company owner # Return legacy format for compatibility
is_owner = session.user_id and str(post.user_id) == session.user_id if result.get("success") and result.get("media_item"):
is_company_owner = False return {"success": True, "image_url": result["media_item"]["url"]}
if not is_owner and session.account_type == "company" and session.company_id:
profile = await db.get_profile(post.user_id)
if profile and profile.company_id and str(profile.company_id) == session.company_id:
is_company_owner = True
if not is_owner and not is_company_owner:
raise HTTPException(status_code=403, detail="Not authorized")
# Read file from form data return result
form = await request.form()
image = form.get("image")
if not image or not hasattr(image, "read"):
raise HTTPException(status_code=400, detail="No image file provided")
file_content = await image.read()
content_type = image.content_type or "application/octet-stream"
# Delete old image if exists
if post.image_url:
try:
await storage.delete_image(post.image_url)
except Exception as e:
logger.warning(f"Failed to delete old image: {e}")
# Upload new image
image_url = await storage.upload_image(
file_content=file_content,
content_type=content_type,
user_id=str(post.user_id),
)
# Update post record
await db.update_generated_post(UUID(post_id), {"image_url": image_url})
return {"success": True, "image_url": image_url}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception(f"Failed to upload post image: {e}")
raise HTTPException(status_code=500, detail=str(e))
@user_router.delete("/api/posts/{post_id}/image") @user_router.delete("/api/posts/{post_id}/image")
@@ -3531,6 +3506,209 @@ async def delete_post_image(request: Request, post_id: str):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ==================== POST MEDIA UPLOAD (MULTI-MEDIA) ====================
@user_router.post("/api/posts/{post_id}/media")
async def upload_post_media(request: Request, post_id: str):
"""Upload a media item (image or video). Max 3 items per post."""
session = require_user_session(request)
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
post = await db.get_generated_post(UUID(post_id))
if not post:
raise HTTPException(status_code=404, detail="Post not found")
# Check authorization: owner or company owner
is_owner = session.user_id and str(post.user_id) == session.user_id
is_company_owner = False
if not is_owner and session.account_type == "company" and session.company_id:
profile = await db.get_profile(post.user_id)
if profile and profile.company_id and str(profile.company_id) == session.company_id:
is_company_owner = True
if not is_owner and not is_company_owner:
raise HTTPException(status_code=403, detail="Not authorized")
# Get current media items (convert to dicts with JSON-serializable datetime)
media_items = [
item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item)
for item in (post.media_items or [])
]
# Validate: Max 3 items
if len(media_items) >= 3:
raise HTTPException(status_code=400, detail="Maximal 3 Medien erlaubt")
# Read file from form data
form = await request.form()
file = form.get("file") or form.get("image") # Support both 'file' and 'image' keys
if not file or not hasattr(file, "read"):
raise HTTPException(status_code=400, detail="No file provided")
file_content = await file.read()
content_type = file.content_type or "application/octet-stream"
# Validate media type consistency (no mixing)
is_video = content_type.startswith("video/")
has_videos = any(item.get("type") == "video" for item in media_items)
has_images = any(item.get("type") == "image" for item in media_items)
if (is_video and has_images) or (not is_video and has_videos):
raise HTTPException(status_code=400, detail="Kann nicht Bilder und Videos mischen")
# Only allow 1 video max
if is_video and len(media_items) > 0:
raise HTTPException(status_code=400, detail="Nur 1 Video pro Post erlaubt")
# Upload to storage
media_url = await storage.upload_media(
file_content=file_content,
content_type=content_type,
user_id=str(post.user_id),
)
# Add to array
new_item = {
"type": "video" if is_video else "image",
"url": media_url,
"order": len(media_items),
"content_type": content_type,
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"metadata": None
}
media_items.append(new_item)
# Update DB
await db.update_generated_post(UUID(post_id), {"media_items": media_items})
# Also update image_url for backward compatibility (first image)
if new_item["type"] == "image" and len([i for i in media_items if i["type"] == "image"]) == 1:
await db.update_generated_post(UUID(post_id), {"image_url": media_url})
return {"success": True, "media_item": new_item, "media_items": media_items}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception(f"Failed to upload post media: {e}")
raise HTTPException(status_code=500, detail=str(e))
@user_router.delete("/api/posts/{post_id}/media/{media_index}")
async def delete_post_media(request: Request, post_id: str, media_index: int):
"""Delete a specific media item by index."""
session = require_user_session(request)
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
post = await db.get_generated_post(UUID(post_id))
if not post:
raise HTTPException(status_code=404, detail="Post not found")
# Check authorization: owner or company owner
is_owner = session.user_id and str(post.user_id) == session.user_id
is_company_owner = False
if not is_owner and session.account_type == "company" and session.company_id:
profile = await db.get_profile(post.user_id)
if profile and profile.company_id and str(profile.company_id) == session.company_id:
is_company_owner = True
if not is_owner and not is_company_owner:
raise HTTPException(status_code=403, detail="Not authorized")
# Get current media items (convert to dicts with JSON-serializable datetime)
media_items = [
item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item)
for item in (post.media_items or [])
]
if media_index < 0 or media_index >= len(media_items):
raise HTTPException(status_code=404, detail="Media item nicht gefunden")
# Delete from storage
item_to_delete = media_items[media_index]
try:
await storage.delete_image(item_to_delete["url"])
except Exception as e:
logger.warning(f"Failed to delete media from storage: {e}")
# Remove and re-index
media_items.pop(media_index)
for i, item in enumerate(media_items):
item["order"] = i
# Update DB
await db.update_generated_post(UUID(post_id), {"media_items": media_items})
# Update image_url for backward compatibility
first_image = next((item for item in media_items if item["type"] == "image"), None)
await db.update_generated_post(UUID(post_id), {"image_url": first_image["url"] if first_image else None})
return {"success": True, "media_items": media_items}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to delete post media: {e}")
raise HTTPException(status_code=500, detail=str(e))
@user_router.put("/api/posts/{post_id}/media/reorder")
async def reorder_post_media(request: Request, post_id: str):
"""Reorder media items. Expects JSON: {"order": [2, 0, 1]}"""
session = require_user_session(request)
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
post = await db.get_generated_post(UUID(post_id))
if not post:
raise HTTPException(status_code=404, detail="Post not found")
# Check authorization: owner or company owner
is_owner = session.user_id and str(post.user_id) == session.user_id
is_company_owner = False
if not is_owner and session.account_type == "company" and session.company_id:
profile = await db.get_profile(post.user_id)
if profile and profile.company_id and str(profile.company_id) == session.company_id:
is_company_owner = True
if not is_owner and not is_company_owner:
raise HTTPException(status_code=403, detail="Not authorized")
# Get current media items (convert to dicts with JSON-serializable datetime)
media_items = [
item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item)
for item in (post.media_items or [])
]
# Parse request body
body = await request.json()
new_order = body.get("order", [])
# Validate order array
if len(new_order) != len(media_items) or set(new_order) != set(range(len(media_items))):
raise HTTPException(status_code=400, detail="Invalid order indices")
# Reorder
reordered = [media_items[i] for i in new_order]
for i, item in enumerate(reordered):
item["order"] = i
# Update DB
await db.update_generated_post(UUID(post_id), {"media_items": reordered})
return {"success": True, "media_items": reordered}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to reorder post media: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== IMAGE PROXY FOR HTTPS ==================== # ==================== IMAGE PROXY FOR HTTPS ====================
@user_router.get("/proxy/image/{bucket}/{path:path}") @user_router.get("/proxy/image/{bucket}/{path:path}")