multiple media upload and smartphone preview
This commit is contained in:
27
config/migrate_add_media_items.sql
Normal file
27
config/migrate_add_media_items.sql
Normal 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}]';
|
||||||
@@ -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 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}/"
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMediaGrid() {
|
||||||
|
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('');
|
||||||
|
|
||||||
|
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 {
|
} finally {
|
||||||
btn.innerHTML = originalHTML;
|
isSaving = false;
|
||||||
btn.disabled = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
function initMediaUpload() {
|
||||||
const uploadZone = document.getElementById('imageUploadZone');
|
const uploadZone = document.getElementById('mediaUploadZone');
|
||||||
const fileInput = document.getElementById('imageFileInput');
|
const fileInput = document.getElementById('mediaFileInput');
|
||||||
const replaceInput = document.getElementById('imageReplaceInput');
|
|
||||||
|
if (!uploadZone) return;
|
||||||
|
|
||||||
if (uploadZone) {
|
|
||||||
uploadZone.addEventListener('click', () => fileInput.click());
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
|
fileInput.addEventListener('change', (e) => {
|
||||||
if (replaceInput) {
|
if (e.target.files[0]) handleMediaUpload(e.target.files[0]);
|
||||||
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(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('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 %}
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user