fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2m15s
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2m15s
This commit is contained in:
@@ -6,7 +6,7 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from config.database import get_db
|
from config.database import get_db
|
||||||
from config.settings import settings
|
from config.settings import settings
|
||||||
from models.vlog import Vlog, VlogLike, VlogComment
|
from models.vlog import Vlog, VlogLike, VlogComment, VlogView
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
|
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
|
||||||
from utils.security import get_current_active_user
|
from utils.security import get_current_active_user
|
||||||
@@ -56,8 +56,21 @@ async def get_vlog(
|
|||||||
detail="Vlog not found"
|
detail="Vlog not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Increment view count
|
# Manage views and replays
|
||||||
|
view = db.query(VlogView).filter(
|
||||||
|
VlogView.vlog_id == vlog_id,
|
||||||
|
VlogView.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if view:
|
||||||
|
# User has already viewed this vlog -> Count as replay
|
||||||
|
vlog.replays_count = (vlog.replays_count or 0) + 1
|
||||||
|
else:
|
||||||
|
# First time viewing -> Count as unique view
|
||||||
|
new_view = VlogView(vlog_id=vlog_id, user_id=current_user.id)
|
||||||
|
db.add(new_view)
|
||||||
vlog.views_count += 1
|
vlog.views_count += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return format_vlog_response(vlog, db, current_user.id)
|
return format_vlog_response(vlog, db, current_user.id)
|
||||||
@@ -149,10 +162,15 @@ async def toggle_vlog_like(
|
|||||||
message = "Like removed"
|
message = "Like removed"
|
||||||
else:
|
else:
|
||||||
# Like
|
# Like
|
||||||
|
try:
|
||||||
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
|
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
|
||||||
db.add(like)
|
db.add(like)
|
||||||
vlog.likes_count += 1
|
vlog.likes_count += 1
|
||||||
message = "Vlog liked"
|
message = "Vlog liked"
|
||||||
|
except Exception:
|
||||||
|
# Handle potential race condition or constraint violation
|
||||||
|
db.rollback()
|
||||||
|
return {"message": "Already liked", "likes_count": vlog.likes_count}
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": message, "likes_count": vlog.likes_count}
|
return {"message": message, "likes_count": vlog.likes_count}
|
||||||
@@ -244,7 +262,7 @@ async def upload_vlog_video(
|
|||||||
|
|
||||||
# Check file size
|
# Check file size
|
||||||
video_content = await video.read()
|
video_content = await video.read()
|
||||||
max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4")
|
max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4", is_vlog=True)
|
||||||
if len(video_content) > max_size:
|
if len(video_content) > max_size:
|
||||||
max_size_mb = max_size // (1024 * 1024)
|
max_size_mb = max_size // (1024 * 1024)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -363,6 +381,7 @@ def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:
|
|||||||
"thumbnail_url": vlog.thumbnail_url,
|
"thumbnail_url": vlog.thumbnail_url,
|
||||||
"duration": vlog.duration,
|
"duration": vlog.duration,
|
||||||
"views_count": vlog.views_count,
|
"views_count": vlog.views_count,
|
||||||
|
"replays_count": vlog.replays_count,
|
||||||
"likes_count": vlog.likes_count,
|
"likes_count": vlog.likes_count,
|
||||||
"created_at": vlog.created_at,
|
"created_at": vlog.created_at,
|
||||||
"updated_at": vlog.updated_at,
|
"updated_at": vlog.updated_at,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request, Response, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
from config.settings import settings
|
from config.settings import settings
|
||||||
from config.database import engine, Base
|
from config.database import engine, Base
|
||||||
@@ -198,10 +199,89 @@ app.add_middleware(
|
|||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=["Content-Range", "Accept-Ranges"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mount static files for uploads
|
# Endpoint personnalisé pour servir les vidéos avec support Range
|
||||||
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads")
|
@app.get("/uploads/{file_path:path}")
|
||||||
|
async def serve_media_with_range(request: Request, file_path: str):
|
||||||
|
"""
|
||||||
|
Serve media files with proper Range request support for video scrubbing.
|
||||||
|
"""
|
||||||
|
file_full_path = Path(settings.UPLOAD_PATH) / file_path
|
||||||
|
|
||||||
|
# Vérifier que le fichier existe
|
||||||
|
if not file_full_path.exists() or not file_full_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
# Vérifier que le fichier est dans le répertoire uploads (sécurité)
|
||||||
|
try:
|
||||||
|
file_full_path.resolve().relative_to(Path(settings.UPLOAD_PATH).resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Obtenir la taille du fichier
|
||||||
|
file_size = file_full_path.stat().st_size
|
||||||
|
|
||||||
|
# Déterminer le content type
|
||||||
|
content_type, _ = mimetypes.guess_type(str(file_full_path))
|
||||||
|
if not content_type:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
# Gérer les requêtes Range
|
||||||
|
range_header = request.headers.get("Range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
# Parser le header Range (format: bytes=start-end)
|
||||||
|
range_match = range_header.replace("bytes=", "").split("-")
|
||||||
|
start = int(range_match[0]) if range_match[0] else 0
|
||||||
|
end = int(range_match[1]) if range_match[1] and range_match[1] else file_size - 1
|
||||||
|
|
||||||
|
# Valider la plage
|
||||||
|
if start >= file_size or end >= file_size or start > end:
|
||||||
|
return Response(
|
||||||
|
status_code=416,
|
||||||
|
headers={
|
||||||
|
"Content-Range": f"bytes */{file_size}",
|
||||||
|
"Accept-Ranges": "bytes"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lire la plage demandée
|
||||||
|
chunk_size = end - start + 1
|
||||||
|
with open(file_full_path, "rb") as f:
|
||||||
|
f.seek(start)
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
|
||||||
|
# Retourner la réponse 206 Partial Content
|
||||||
|
return Response(
|
||||||
|
content=chunk,
|
||||||
|
status_code=206,
|
||||||
|
headers={
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(chunk_size),
|
||||||
|
"Content-Type": content_type,
|
||||||
|
},
|
||||||
|
media_type=content_type
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Pas de Range header, retourner le fichier complet
|
||||||
|
with open(file_full_path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
headers={
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
"Content-Type": content_type,
|
||||||
|
},
|
||||||
|
media_type=content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: StaticFiles mount retiré car notre endpoint personnalisé gère tous les fichiers
|
||||||
|
# avec support Range pour permettre le scrubbing vidéo
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from config.database import Base
|
from config.database import Base
|
||||||
@@ -14,6 +14,7 @@ class Vlog(Base):
|
|||||||
thumbnail_url = Column(String)
|
thumbnail_url = Column(String)
|
||||||
duration = Column(Integer) # in seconds
|
duration = Column(Integer) # in seconds
|
||||||
views_count = Column(Integer, default=0)
|
views_count = Column(Integer, default=0)
|
||||||
|
replays_count = Column(Integer, default=0)
|
||||||
likes_count = Column(Integer, default=0)
|
likes_count = Column(Integer, default=0)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -35,6 +36,10 @@ class VlogLike(Base):
|
|||||||
vlog = relationship("Vlog", back_populates="likes")
|
vlog = relationship("Vlog", back_populates="likes")
|
||||||
user = relationship("User", back_populates="vlog_likes")
|
user = relationship("User", back_populates="vlog_likes")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_like_uc'),
|
||||||
|
)
|
||||||
|
|
||||||
class VlogComment(Base):
|
class VlogComment(Base):
|
||||||
__tablename__ = "vlog_comments"
|
__tablename__ = "vlog_comments"
|
||||||
|
|
||||||
@@ -48,3 +53,19 @@ class VlogComment(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
vlog = relationship("Vlog", back_populates="comments")
|
vlog = relationship("Vlog", back_populates="comments")
|
||||||
user = relationship("User", back_populates="vlog_comments")
|
user = relationship("User", back_populates="vlog_comments")
|
||||||
|
|
||||||
|
class VlogView(Base):
|
||||||
|
__tablename__ = "vlog_views"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
vlog_id = Column(Integer, ForeignKey("vlogs.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vlog = relationship("Vlog")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_view_uc'),
|
||||||
|
)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class VlogResponse(VlogBase):
|
|||||||
thumbnail_url: Optional[str]
|
thumbnail_url: Optional[str]
|
||||||
duration: Optional[int]
|
duration: Optional[int]
|
||||||
views_count: int
|
views_count: int
|
||||||
|
replays_count: int = 0
|
||||||
likes_count: int
|
likes_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -1,49 +1,53 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any, Dict, List
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from models.settings import SystemSettings
|
from models.settings import SystemSettings
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
import json
|
|
||||||
|
|
||||||
class SettingsService:
|
class SettingsService:
|
||||||
"""Service for managing system settings."""
|
"""Service for managing system settings."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_setting(db: Session, key: str, default: Any = None) -> Any:
|
def get_setting(db: Session, key: str, default: Any = None) -> Any:
|
||||||
"""Get a setting value by key."""
|
"""Get a setting value by key, return default if not found."""
|
||||||
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||||
if not setting:
|
if not setting:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
# Essayer de convertir en type approprié
|
# Convert value based on expected type (basic handling)
|
||||||
value = setting.value
|
if isinstance(default, int):
|
||||||
|
|
||||||
# Booléens
|
|
||||||
if value.lower() in ['true', 'false']:
|
|
||||||
return value.lower() == 'true'
|
|
||||||
|
|
||||||
# Nombres entiers
|
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(setting.value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
return default
|
||||||
|
elif isinstance(default, bool):
|
||||||
|
return setting.value.lower() == "true"
|
||||||
|
elif isinstance(default, list):
|
||||||
|
return setting.value.split(",")
|
||||||
|
|
||||||
# Nombres flottants
|
return setting.value
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# JSON
|
@staticmethod
|
||||||
if value.startswith('{') or value.startswith('['):
|
def set_setting(db: Session, key: str, value: str, description: str = None, category: str = "general") -> SystemSettings:
|
||||||
try:
|
"""Set a setting value."""
|
||||||
return json.loads(value)
|
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||||
except json.JSONDecodeError:
|
if setting:
|
||||||
pass
|
setting.value = str(value)
|
||||||
|
if description:
|
||||||
|
setting.description = description
|
||||||
|
if category:
|
||||||
|
setting.category = category
|
||||||
|
else:
|
||||||
|
setting = SystemSettings(
|
||||||
|
key=key,
|
||||||
|
value=str(value),
|
||||||
|
description=description,
|
||||||
|
category=category
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
# Liste séparée par des virgules
|
db.commit()
|
||||||
if ',' in value and not value.startswith('{') and not value.startswith('['):
|
db.refresh(setting)
|
||||||
return [item.strip() for item in value.split(',')]
|
return setting
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_upload_limits(db: Session) -> Dict[str, Any]:
|
def get_upload_limits(db: Session) -> Dict[str, Any]:
|
||||||
@@ -60,7 +64,7 @@ class SettingsService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_max_upload_size(db: Session, content_type: str) -> int:
|
def get_max_upload_size(db: Session, content_type: str, is_vlog: bool = False) -> int:
|
||||||
"""Get max upload size for a specific content type."""
|
"""Get max upload size for a specific content type."""
|
||||||
if content_type.startswith('image/'):
|
if content_type.startswith('image/'):
|
||||||
max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10)
|
max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10)
|
||||||
@@ -68,9 +72,14 @@ class SettingsService:
|
|||||||
print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
|
print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
|
||||||
return max_size_bytes
|
return max_size_bytes
|
||||||
elif content_type.startswith('video/'):
|
elif content_type.startswith('video/'):
|
||||||
|
if is_vlog:
|
||||||
|
max_size_mb = SettingsService.get_setting(db, "max_vlog_size_mb", 500)
|
||||||
|
print(f"DEBUG - Vlog upload limit: {max_size_mb}MB")
|
||||||
|
else:
|
||||||
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100)
|
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100)
|
||||||
|
print(f"DEBUG - Video upload limit: {max_size_mb}MB")
|
||||||
|
|
||||||
max_size_bytes = max_size_mb * 1024 * 1024
|
max_size_bytes = max_size_mb * 1024 * 1024
|
||||||
print(f"DEBUG - Video upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
|
|
||||||
return max_size_bytes
|
return max_size_bytes
|
||||||
else:
|
else:
|
||||||
default_size = 10 * 1024 * 1024 # 10MB par défaut
|
default_size = 10 * 1024 * 1024 # 10MB par défaut
|
||||||
@@ -79,14 +88,17 @@ class SettingsService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_file_type_allowed(db: Session, content_type: str) -> bool:
|
def is_file_type_allowed(db: Session, content_type: str) -> bool:
|
||||||
"""Check if a file type is allowed."""
|
"""Check if file type is allowed."""
|
||||||
if content_type.startswith('image/'):
|
if content_type.startswith('image/'):
|
||||||
allowed_types = SettingsService.get_setting(db, "allowed_image_types",
|
allowed = SettingsService.get_setting(db, "allowed_image_types",
|
||||||
["image/jpeg", "image/png", "image/gif", "image/webp"])
|
["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
|
if isinstance(allowed, str):
|
||||||
|
allowed = allowed.split(",")
|
||||||
|
return content_type in allowed
|
||||||
elif content_type.startswith('video/'):
|
elif content_type.startswith('video/'):
|
||||||
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
allowed = SettingsService.get_setting(db, "allowed_video_types",
|
||||||
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
||||||
else:
|
if isinstance(allowed, str):
|
||||||
|
allowed = allowed.split(",")
|
||||||
|
return content_type in allowed
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return content_type in allowed_types
|
|
||||||
|
|||||||
@@ -2,29 +2,37 @@
|
|||||||
<div class="video-player-container">
|
<div class="video-player-container">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- Video.js Player -->
|
<!-- Video.js Player -->
|
||||||
|
<div data-vjs-player>
|
||||||
<video
|
<video
|
||||||
ref="videoPlayer"
|
ref="videoPlayer"
|
||||||
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
||||||
controls
|
controls
|
||||||
preload="auto"
|
preload="auto"
|
||||||
:poster="posterUrl"
|
:poster="posterUrl"
|
||||||
data-setup="{}"
|
playsinline
|
||||||
>
|
>
|
||||||
<source :src="videoUrl" type="video/mp4" />
|
<source :src="videoUrl" />
|
||||||
<p class="vjs-no-js">
|
<p class="vjs-no-js">
|
||||||
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
||||||
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
||||||
</p>
|
</p>
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Video Stats -->
|
<!-- Video Stats -->
|
||||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-600">
|
<div class="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<span class="flex items-center">
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="flex items-center" title="Vues uniques">
|
||||||
<Eye class="w-4 h-4 mr-1" />
|
<Eye class="w-4 h-4 mr-1" />
|
||||||
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }}
|
{{ viewsCount }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="flex items-center" title="Replays">
|
||||||
|
<RotateCcw class="w-4 h-4 mr-1" />
|
||||||
|
{{ replaysCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<Clock class="w-4 h-4 mr-1" />
|
<Clock class="w-4 h-4 mr-1" />
|
||||||
{{ formatDuration(duration) }}
|
{{ formatDuration(duration) }}
|
||||||
@@ -57,7 +65,7 @@
|
|||||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import 'video.js/dist/video-js.css'
|
import 'video.js/dist/video-js.css'
|
||||||
import { Eye, Clock, Heart, MessageSquare } from 'lucide-vue-next'
|
import { Eye, Clock, Heart, MessageSquare, RotateCcw } from 'lucide-vue-next'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -85,6 +93,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
|
replaysCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
likesCount: {
|
likesCount: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
@@ -103,6 +115,7 @@ const emit = defineEmits(['like', 'toggle-comments'])
|
|||||||
|
|
||||||
const videoPlayer = ref(null)
|
const videoPlayer = ref(null)
|
||||||
const player = ref(null)
|
const player = ref(null)
|
||||||
|
const currentVideoSrc = ref(null) // Track la source actuelle pour éviter les rechargements inutiles
|
||||||
|
|
||||||
// Computed properties pour les URLs
|
// Computed properties pour les URLs
|
||||||
const videoUrl = computed(() => getMediaUrl(props.src))
|
const videoUrl = computed(() => getMediaUrl(props.src))
|
||||||
@@ -124,16 +137,62 @@ function toggleComments() {
|
|||||||
emit('toggle-comments')
|
emit('toggle-comments')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fonction pour gérer les raccourcis clavier manuellement
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (!player.value) return;
|
||||||
|
|
||||||
|
// Ignorer si l'utilisateur tape dans un input
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||||
|
|
||||||
|
switch(e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
player.value.currentTime(Math.max(0, player.value.currentTime() - 10));
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
player.value.currentTime(Math.min(player.value.duration(), player.value.currentTime() + 10));
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
case 'Space': // Espace pour pause/play
|
||||||
|
e.preventDefault();
|
||||||
|
if (player.value.paused()) {
|
||||||
|
player.value.play();
|
||||||
|
} else {
|
||||||
|
player.value.pause();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (videoPlayer.value) {
|
if (videoPlayer.value) {
|
||||||
player.value = videojs(videoPlayer.value, {
|
// Options de base pour Video.js
|
||||||
|
const options = {
|
||||||
controls: true,
|
controls: true,
|
||||||
fluid: true,
|
fluid: true,
|
||||||
responsive: true,
|
responsive: true,
|
||||||
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
|
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
|
||||||
|
// Désactiver les hotkeys natifs qui causent l'erreur passive listener
|
||||||
|
userActions: {
|
||||||
|
hotkeys: false
|
||||||
|
},
|
||||||
|
html5: {
|
||||||
|
vhs: {
|
||||||
|
overrideNative: true
|
||||||
|
},
|
||||||
|
nativeAudioTracks: false,
|
||||||
|
nativeVideoTracks: false
|
||||||
|
},
|
||||||
controlBar: {
|
controlBar: {
|
||||||
|
skipButtons: {
|
||||||
|
forward: 10,
|
||||||
|
backward: 10
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
'playToggle',
|
'playToggle',
|
||||||
|
'skipBackward',
|
||||||
|
'skipForward',
|
||||||
'volumePanel',
|
'volumePanel',
|
||||||
'currentTimeDisplay',
|
'currentTimeDisplay',
|
||||||
'timeDivider',
|
'timeDivider',
|
||||||
@@ -143,12 +202,23 @@ onMounted(() => {
|
|||||||
'fullscreenToggle'
|
'fullscreenToggle'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
|
||||||
|
player.value = videojs(videoPlayer.value, options);
|
||||||
|
|
||||||
|
// Définir la source initiale après l'initialisation
|
||||||
|
if (videoUrl.value) {
|
||||||
|
player.value.src({ src: videoUrl.value, type: 'video/mp4' })
|
||||||
|
currentVideoSrc.value = videoUrl.value
|
||||||
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
player.value.on('error', (error) => {
|
player.value.on('error', (error) => {
|
||||||
console.error('Video.js error:', error)
|
console.error('Video.js error:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Ajouter l'écouteur d'événements clavier global
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -156,20 +226,39 @@ onBeforeUnmount(() => {
|
|||||||
if (player.value) {
|
if (player.value) {
|
||||||
player.value.dispose()
|
player.value.dispose()
|
||||||
}
|
}
|
||||||
|
// Retirer l'écouteur
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for src changes to reload video
|
// Watch for src changes to reload video - amélioré pour éviter les rechargements inutiles
|
||||||
watch(() => props.src, () => {
|
watch(() => videoUrl.value, (newUrl, oldUrl) => {
|
||||||
if (player.value && videoUrl.value) {
|
// Ne recharger que si l'URL a vraiment changé et que le player est prêt
|
||||||
player.value.src({ src: videoUrl.value, type: 'video/mp4' })
|
if (player.value && newUrl && newUrl !== currentVideoSrc.value) {
|
||||||
|
const wasPlaying = !player.value.paused()
|
||||||
|
const currentTime = player.value.currentTime()
|
||||||
|
|
||||||
|
player.value.src({ src: newUrl, type: 'video/mp4' })
|
||||||
player.value.load()
|
player.value.load()
|
||||||
|
currentVideoSrc.value = newUrl
|
||||||
|
|
||||||
|
// Restaurer la position si possible (optionnel)
|
||||||
|
player.value.ready(() => {
|
||||||
|
if (currentTime > 0 && currentTime < player.value.duration()) {
|
||||||
|
player.value.currentTime(currentTime)
|
||||||
|
}
|
||||||
|
if (wasPlaying) {
|
||||||
|
player.value.play().catch(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}, { immediate: false })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.video-js {
|
.video-js {
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
|
/* Fix pour l'erreur "passive event listener" sur certains navigateurs */
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-js .vjs-big-play-button {
|
.video-js .vjs-big-play-button {
|
||||||
|
|||||||
@@ -122,12 +122,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<div class="flex items-center sm:hidden ml-4">
|
||||||
|
<button
|
||||||
|
@click="isMobileMenuOpen = !isMobileMenuOpen"
|
||||||
|
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open main menu</span>
|
||||||
|
<Menu v-if="!isMobileMenuOpen" class="block h-6 w-6" aria-hidden="true" />
|
||||||
|
<X v-else class="block h-6 w-6" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu -->
|
<!-- Mobile menu -->
|
||||||
<div class="sm:hidden">
|
<div class="sm:hidden border-t border-gray-200" v-show="isMobileMenuOpen">
|
||||||
<div class="pt-2 pb-3 space-y-1">
|
<div class="pt-2 pb-3 space-y-1">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in navigation"
|
v-for="item in navigation"
|
||||||
@@ -229,7 +241,8 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
User,
|
User,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
X
|
X,
|
||||||
|
Menu
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
||||||
@@ -247,6 +260,7 @@ const navigation = [
|
|||||||
|
|
||||||
const showUserMenu = ref(false)
|
const showUserMenu = ref(false)
|
||||||
const showNotifications = ref(false)
|
const showNotifications = ref(false)
|
||||||
|
const isMobileMenuOpen = ref(false)
|
||||||
|
|
||||||
const user = computed(() => authStore.user)
|
const user = computed(() => authStore.user)
|
||||||
const notifications = computed(() => authStore.notifications)
|
const notifications = computed(() => authStore.notifications)
|
||||||
|
|||||||
@@ -18,30 +18,30 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
|
||||||
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
|
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
Retour aux albums
|
Retour aux albums
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div v-if="canEdit" class="flex space-x-2">
|
<div v-if="canEdit" class="flex flex-wrap gap-2 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
@click="showEditModal = true"
|
@click="showEditModal = true"
|
||||||
class="btn-secondary"
|
class="flex-1 sm:flex-none btn-secondary justify-center"
|
||||||
>
|
>
|
||||||
<Edit class="w-4 h-4 mr-2" />
|
<Edit class="w-4 h-4 mr-2" />
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showUploadModal = true"
|
@click="showUploadModal = true"
|
||||||
class="btn-primary"
|
class="flex-1 sm:flex-none btn-primary justify-center"
|
||||||
>
|
>
|
||||||
<Upload class="w-4 h-4 mr-2" />
|
<Upload class="w-4 h-4 mr-2" />
|
||||||
Ajouter des médias
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="deleteAlbum"
|
@click="deleteAlbum"
|
||||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4 mr-2" />
|
<Trash2 class="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start space-x-6">
|
<div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
|
||||||
<!-- Cover Image -->
|
<!-- Cover Image -->
|
||||||
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center">
|
<div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
|
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Albums photos</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Albums photos</h1>
|
||||||
<p class="text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
|
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
class="btn-primary"
|
class="w-full sm:w-auto btn-primary justify-center"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
Nouvel album
|
Nouvel album
|
||||||
|
|||||||
@@ -18,23 +18,23 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
|
||||||
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
|
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
Retour aux événements
|
Retour aux événements
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div v-if="canEdit" class="flex space-x-2">
|
<div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
|
||||||
<button
|
<button
|
||||||
@click="showEditModal = true"
|
@click="showEditModal = true"
|
||||||
class="btn-secondary"
|
class="flex-1 sm:flex-none btn-secondary justify-center"
|
||||||
>
|
>
|
||||||
<Edit class="w-4 h-4 mr-2" />
|
<Edit class="w-4 h-4 mr-2" />
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="deleteEvent"
|
@click="deleteEvent"
|
||||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4 mr-2" />
|
<Trash2 class="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start space-x-6">
|
<div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
|
||||||
<!-- Cover Image -->
|
<!-- Cover Image -->
|
||||||
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center">
|
<div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
v-if="!event.cover_image && event.creator_avatar"
|
v-if="!event.cover_image && event.creator_avatar"
|
||||||
:src="getMediaUrl(event.creator_avatar)"
|
:src="getMediaUrl(event.creator_avatar)"
|
||||||
@@ -122,25 +122,6 @@
|
|||||||
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
|
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map Section -->
|
|
||||||
<div v-if="event.latitude && event.longitude" class="card p-6 mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Localisation</h2>
|
|
||||||
<div class="bg-gray-100 rounded-lg p-6 h-48 flex items-center justify-center">
|
|
||||||
<div class="text-center text-gray-600">
|
|
||||||
<MapPin class="w-12 h-12 mx-auto mb-2 text-primary-600" />
|
|
||||||
<p class="text-sm">Carte interactive</p>
|
|
||||||
<p class="text-xs mt-1">{{ event.latitude }}, {{ event.longitude }}</p>
|
|
||||||
<a
|
|
||||||
:href="`https://www.openstreetmap.org/?mlat=${event.latitude}&mlon=${event.longitude}&zoom=15`"
|
|
||||||
target="_blank"
|
|
||||||
class="text-primary-600 hover:underline text-sm mt-2 inline-block"
|
|
||||||
>
|
|
||||||
Voir sur OpenStreetMap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- My Participation -->
|
<!-- My Participation -->
|
||||||
<div class="card p-6 mb-8">
|
<div class="card p-6 mb-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Événements</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Événements</h1>
|
||||||
<p class="text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
|
<p class="text-sm sm:text-base text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
class="btn-primary"
|
class="w-full sm:w-auto btn-primary justify-center"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
Nouvel événement
|
Nouvel événement
|
||||||
@@ -214,51 +214,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map Section -->
|
|
||||||
<div>
|
|
||||||
<label class="label">Coordonnées géographiques (optionnel)</label>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-600">Latitude</label>
|
|
||||||
<input
|
|
||||||
v-model="newEvent.latitude"
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
class="input"
|
|
||||||
placeholder="48.8566"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-600">Longitude</label>
|
|
||||||
<input
|
|
||||||
v-model="newEvent.longitude"
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
class="input"
|
|
||||||
placeholder="2.3522"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Map Preview -->
|
|
||||||
<div v-if="newEvent.latitude && newEvent.longitude" class="mt-3">
|
|
||||||
<div class="bg-gray-100 rounded-lg p-4 h-32 flex items-center justify-center">
|
|
||||||
<div class="text-center text-gray-600">
|
|
||||||
<MapPin class="w-8 h-8 mx-auto mb-2 text-primary-600" />
|
|
||||||
<p class="text-sm">Localisation sélectionnée</p>
|
|
||||||
<p class="text-xs mt-1">{{ newEvent.latitude }}, {{ newEvent.longitude }}</p>
|
|
||||||
<a
|
|
||||||
:href="`https://www.openstreetmap.org/?mlat=${newEvent.latitude}&mlon=${newEvent.longitude}&zoom=15`"
|
|
||||||
target="_blank"
|
|
||||||
class="text-primary-600 hover:underline text-xs mt-2 inline-block"
|
|
||||||
>
|
|
||||||
Voir sur OpenStreetMap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Date et heure</label>
|
<label class="label">Date et heure</label>
|
||||||
@@ -339,8 +294,6 @@ const newEvent = ref({
|
|||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
location: '',
|
location: '',
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
end_date: null
|
end_date: null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -453,8 +406,6 @@ async function createEvent() {
|
|||||||
description: newEvent.value.description,
|
description: newEvent.value.description,
|
||||||
date: new Date(newEvent.value.date).toISOString(),
|
date: new Date(newEvent.value.date).toISOString(),
|
||||||
location: newEvent.value.location,
|
location: newEvent.value.location,
|
||||||
latitude: newEvent.value.latitude,
|
|
||||||
longitude: newEvent.value.longitude,
|
|
||||||
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
|
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,8 +429,6 @@ function resetForm() {
|
|||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
location: '',
|
location: '',
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
end_date: null
|
end_date: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||||
<!-- Welcome Section -->
|
<!-- Welcome Section -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
<!-- Quick Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<div class="card p-6">
|
<div class="card p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600">Prochain événement</p>
|
<p class="text-sm text-gray-600">Prochain événement</p>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-6">
|
<div class="card p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600">Taux de présence</p>
|
<p class="text-sm text-gray-600">Taux de présence</p>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-6">
|
<div class="card p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600">Nouveaux posts</p>
|
<p class="text-sm text-gray-600">Nouveaux posts</p>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-6">
|
<div class="card p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600">Membres actifs</p>
|
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||||
@@ -59,17 +59,17 @@
|
|||||||
<!-- Recent Posts -->
|
<!-- Recent Posts -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="p-6 border-b border-gray-100">
|
<div class="p-4 sm:p-6 border-b border-gray-100">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
<div v-if="posts.length === 0" class="p-6 text-center text-gray-500">
|
<div v-if="posts.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
|
||||||
Aucune publication récente
|
Aucune publication récente
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="post in posts"
|
v-for="post in posts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
class="p-6 hover:bg-gray-50 transition-colors"
|
class="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="flex items-start space-x-3">
|
<div class="flex items-start space-x-3">
|
||||||
<img
|
<img
|
||||||
@@ -133,11 +133,11 @@
|
|||||||
<!-- Upcoming Events -->
|
<!-- Upcoming Events -->
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="p-6 border-b border-gray-100">
|
<div class="p-4 sm:p-6 border-b border-gray-100">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
<div v-if="upcomingEvents.length === 0" class="p-6 text-center text-gray-500">
|
<div v-if="upcomingEvents.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
|
||||||
Aucun événement prévu
|
Aucun événement prévu
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
@@ -171,11 +171,11 @@
|
|||||||
|
|
||||||
<!-- Recent Vlogs -->
|
<!-- Recent Vlogs -->
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-6 border-b border-gray-100">
|
<div class="p-4 sm:p-6 border-b border-gray-100">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
<div v-if="recentVlogs.length === 0" class="p-6 text-center text-gray-500">
|
<div v-if="recentVlogs.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
|
||||||
Aucun vlog récent
|
Aucun vlog récent
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Publications</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Publications</h1>
|
||||||
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
class="btn-primary"
|
class="w-full sm:w-auto btn-primary justify-center"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
Nouvelle publication
|
Nouvelle publication
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Post Form -->
|
<!-- Create Post Form -->
|
||||||
<div class="card p-6 mb-8">
|
<div class="card p-4 sm:p-6 mb-8">
|
||||||
<div class="flex items-start space-x-3">
|
<div class="flex items-start space-x-3">
|
||||||
<img
|
<img
|
||||||
v-if="user?.avatar_url"
|
v-if="user?.avatar_url"
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="post in posts"
|
v-for="post in posts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
class="card p-6"
|
class="card p-4 sm:p-6"
|
||||||
>
|
>
|
||||||
<!-- Post Header -->
|
<!-- Post Header -->
|
||||||
<div class="flex items-start space-x-3 mb-4">
|
<div class="flex items-start space-x-3 mb-4">
|
||||||
|
|||||||
@@ -18,23 +18,23 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
|
||||||
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
|
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
Retour aux vlogs
|
Retour aux vlogs
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div v-if="canEdit" class="flex space-x-2">
|
<div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
|
||||||
<button
|
<button
|
||||||
@click="showEditModal = true"
|
@click="showEditModal = true"
|
||||||
class="btn-secondary"
|
class="flex-1 sm:flex-none btn-secondary justify-center"
|
||||||
>
|
>
|
||||||
<Edit class="w-4 h-4 mr-2" />
|
<Edit class="w-4 h-4 mr-2" />
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="deleteVlog"
|
@click="deleteVlog"
|
||||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4 mr-2" />
|
<Trash2 class="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1>
|
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1>
|
||||||
|
|
||||||
<div class="flex items-center space-x-6 text-gray-600 mb-6">
|
<div class="flex flex-wrap items-center gap-4 text-sm sm:text-base text-gray-600 mb-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img
|
<img
|
||||||
v-if="vlog.author_avatar"
|
v-if="vlog.author_avatar"
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
:alt="vlog.author_name"
|
:alt="vlog.author_name"
|
||||||
class="w-8 h-8 rounded-full object-cover mr-3"
|
class="w-8 h-8 rounded-full object-cover mr-3"
|
||||||
>
|
>
|
||||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-3">
|
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-2">
|
||||||
<User class="w-4 h-4 text-primary-600" />
|
<User class="w-4 h-4 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<span>Par {{ vlog.author_name }}</span>
|
<span>{{ vlog.author_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -68,6 +68,11 @@
|
|||||||
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
|
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<RotateCcw class="w-4 h-4 mr-2" />
|
||||||
|
<span>{{ vlog.replays_count }} replay{{ vlog.replays_count > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="vlog.duration" class="flex items-center">
|
<div v-if="vlog.duration" class="flex items-center">
|
||||||
<Clock class="w-4 h-4 mr-2" />
|
<Clock class="w-4 h-4 mr-2" />
|
||||||
<span>{{ formatDuration(vlog.duration) }}</span>
|
<span>{{ formatDuration(vlog.duration) }}</span>
|
||||||
@@ -76,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Player -->
|
<!-- Video Player -->
|
||||||
<div class="card p-6 mb-8">
|
<div class="card p-0 sm:p-6 mb-8 overflow-hidden sm:overflow-visible">
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
:src="vlog.video_url"
|
:src="vlog.video_url"
|
||||||
:poster="vlog.thumbnail_url"
|
:poster="vlog.thumbnail_url"
|
||||||
@@ -84,6 +89,7 @@
|
|||||||
:description="vlog.description"
|
:description="vlog.description"
|
||||||
:duration="vlog.duration"
|
:duration="vlog.duration"
|
||||||
:views-count="vlog.views_count"
|
:views-count="vlog.views_count"
|
||||||
|
:replays-count="vlog.replays_count"
|
||||||
:likes-count="vlog.likes_count"
|
:likes-count="vlog.likes_count"
|
||||||
:comments-count="vlog.comments?.length || 0"
|
:comments-count="vlog.comments?.length || 0"
|
||||||
:is-liked="vlog.is_liked"
|
:is-liked="vlog.is_liked"
|
||||||
@@ -93,13 +99,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div v-if="vlog.description" class="card p-6 mb-8">
|
<div v-if="vlog.description" class="card p-4 sm:p-6 mb-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||||
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
|
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comments Section -->
|
<!-- Comments Section -->
|
||||||
<div class="card p-6 mb-8">
|
<div class="card p-4 sm:p-6 mb-8">
|
||||||
<VlogComments
|
<VlogComments
|
||||||
:vlog-id="vlog.id"
|
:vlog-id="vlog.id"
|
||||||
:comments="vlog.comments || []"
|
:comments="vlog.comments || []"
|
||||||
@@ -233,7 +239,8 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
Film,
|
Film,
|
||||||
Play
|
Play,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||||
import VlogComments from '@/components/VlogComments.vue'
|
import VlogComments from '@/components/VlogComments.vue'
|
||||||
@@ -279,8 +286,9 @@ function formatDuration(seconds) {
|
|||||||
async function toggleLike() {
|
async function toggleLike() {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
|
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
|
||||||
// Refresh vlog data to get updated like count
|
// Update local state without refreshing full vlog data (avoiding view increment)
|
||||||
await fetchVlog()
|
vlog.value.likes_count = response.data.likes_count
|
||||||
|
vlog.value.is_liked = !vlog.value.is_liked
|
||||||
toast.success(response.data.message)
|
toast.success(response.data.message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Erreur lors de la mise à jour du like')
|
toast.error('Erreur lors de la mise à jour du like')
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Vlogs</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Vlogs</h1>
|
||||||
<p class="text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
|
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
class="btn-primary"
|
class="w-full sm:w-auto btn-primary justify-center"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
Nouveau vlog
|
Nouveau vlog
|
||||||
|
|||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "LeDiscord",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Reference in New Issue
Block a user