mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
feat: replace Cloudinary with MinIO for all media storage
- Create server/services/minio.ts: S3-compatible service (same interface as cloudinaryService) using @aws-sdk/client-s3 and MINIO_ENDPOINT env var - Replace all cloudinaryService imports with minioService across routes.ts, routes/reels.ts, routes/remotion.ts, services/media-purge.ts - Replace Cloudinary logo URL pattern (res.cloudinary.com/...) with buildMinioUrl() helper throughout all server routes - GET /api/cloudinary/config now returns a logoUrl field (full MinIO URL) so clients never need to build the URL themselves - Update settings pages (desktop + mobile): labels changed to MinIO (Bucket Name, Access Key, Secret Key) - Update image-editor.tsx: remove Cloudinary URL transformation, use logoUrl - Update media-utils.ts: simplify getVideoThumbnailUrl (no URL transforms) - Add MINIO_ENDPOINT and MINIO_PUBLIC_URL to docker-compose.yml + .env.example - DB table reuse: cloudinary_config.cloud_name = bucket, api_key = access key, api_secret = secret key — no migration needed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0e11cb8ab
commit
e39fa1026d
13 files changed
+1832
-118
No files matched your search
@@ -1,20 +1,8 @@
|
||||
/**
|
||||
* Derives a Cloudinary video thumbnail URL from the original video URL.
|
||||
*
|
||||
* Cloudinary automatically generates image frames for uploaded videos.
|
||||
* By inserting transformation parameters into the URL, we can extract
|
||||
* a static JPEG thumbnail without any backend change or DB migration.
|
||||
*
|
||||
* @param originalUrl - The Cloudinary video URL (e.g. .../video/upload/v123/file.mp4)
|
||||
* @returns A JPEG thumbnail URL with fill-crop at 400x400
|
||||
* Returns a thumbnail URL for a video.
|
||||
* With MinIO there are no automatic URL-based transformations,
|
||||
* so we return the original URL and let the browser render the video.
|
||||
*/
|
||||
export function getVideoThumbnailUrl(originalUrl: string): string {
|
||||
// Only transform Cloudinary video URLs
|
||||
if (!originalUrl.includes('/video/upload/')) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
return originalUrl
|
||||
.replace('/video/upload/', '/video/upload/so_0,w_400,h_400,c_fill/')
|
||||
.replace(/\.[^.]+$/, '.jpg');
|
||||
return originalUrl;
|
||||
}
|
||||
@@ -112,45 +112,10 @@ export default function ImageEditor() {
|
||||
});
|
||||
|
||||
|
||||
// Generate Cloudinary transformation URL
|
||||
// Set preview URL from selected media
|
||||
useEffect(() => {
|
||||
if (!selectedMedia) {
|
||||
setPreviewUrl("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Start with base image URL
|
||||
let url = selectedMedia.originalUrl;
|
||||
|
||||
// Extract Cloudinary parts
|
||||
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//;
|
||||
const match = url.match(cloudinaryPattern);
|
||||
|
||||
if (!match) {
|
||||
setPreviewUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const [fullMatch, cloudName] = match;
|
||||
const imagePath = url.replace(fullMatch, '');
|
||||
|
||||
// Build transformations array
|
||||
const transformations: string[] = [];
|
||||
|
||||
// Base resize
|
||||
transformations.push('w_800,h_800,c_fit');
|
||||
|
||||
// Note: Ribbon and price badge overlays are rendered as CSS elements in preview
|
||||
// They are NOT added to Cloudinary transformation URL
|
||||
// This allows for better visual control and positioning
|
||||
|
||||
// Logo overlay will be added by backend when saving
|
||||
// Preview just shows the base image with CSS overlays
|
||||
|
||||
// Construct final URL
|
||||
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
|
||||
setPreviewUrl(transformedUrl);
|
||||
|
||||
if (!selectedMedia) { setPreviewUrl(""); return; }
|
||||
setPreviewUrl(selectedMedia.originalUrl);
|
||||
}, [selectedMedia, ribbon, priceBadge, logo]);
|
||||
|
||||
return (
|
||||
@@ -620,9 +585,9 @@ export default function ImageEditor() {
|
||||
)}
|
||||
|
||||
{/* Logo Overlay (CSS) */}
|
||||
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
|
||||
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoUrl && (
|
||||
<img
|
||||
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
|
||||
src={(cloudinaryConfig as any).logoUrl}
|
||||
alt="Logo"
|
||||
className={`absolute ${
|
||||
logo.position === "north_east" ? "top-4 right-4" :
|
||||
|
||||
@@ -51,10 +51,8 @@ export default function SettingsMobile() {
|
||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||
|
||||
if ((cloudinaryConfig as any).logoPublicId) {
|
||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
||||
if ((cloudinaryConfig as any).logoUrl) {
|
||||
setLogoPreview((cloudinaryConfig as any).logoUrl);
|
||||
}
|
||||
}
|
||||
}, [cloudinaryConfig]);
|
||||
@@ -361,48 +359,48 @@ export default function SettingsMobile() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cloudinary */}
|
||||
{/* MinIO */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cloud className="w-5 h-5" />
|
||||
Configuration Cloudinary
|
||||
Configuration Stockage MinIO
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Configurez Cloudinary pour le stockage d'images dans le cloud
|
||||
Configurez MinIO pour le stockage des médias (endpoint via variable MINIO_ENDPOINT)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cloudName" className="text-sm">Cloud Name</Label>
|
||||
<Label htmlFor="cloudName" className="text-sm">Nom du bucket</Label>
|
||||
<Input
|
||||
id="cloudName"
|
||||
type="text"
|
||||
value={cloudName}
|
||||
onChange={(e) => setCloudName(e.target.value)}
|
||||
placeholder="mon-cloud-name"
|
||||
placeholder="socialflow"
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-cloud-name"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Trouvez-le dans votre tableau de bord Cloudinary
|
||||
Nom du bucket MinIO dans lequel stocker les fichiers
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey" className="text-sm">API Key</Label>
|
||||
<Label htmlFor="apiKey" className="text-sm">Access Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="text"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="123456789012345"
|
||||
placeholder="minioadmin"
|
||||
className="min-h-[48px]"
|
||||
data-testid="input-api-key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiSecret" className="text-sm">API Secret</Label>
|
||||
<Label htmlFor="apiSecret" className="text-sm">Secret Key</Label>
|
||||
<Input
|
||||
id="apiSecret"
|
||||
type="password"
|
||||
@@ -413,7 +411,7 @@ export default function SettingsMobile() {
|
||||
data-testid="input-api-secret"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Votre API Secret restera sécurisé et ne sera jamais exposé
|
||||
Votre Secret Key restera sécurisé et ne sera jamais exposé
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -422,7 +420,7 @@ export default function SettingsMobile() {
|
||||
data-testid="button-save-cloudinary"
|
||||
className="w-full min-h-[48px]"
|
||||
>
|
||||
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer Cloudinary"}
|
||||
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer MinIO"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -57,16 +57,8 @@ export default function Settings() {
|
||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||
|
||||
// Set logo preview if exists
|
||||
if ((cloudinaryConfig as any).logoPublicId) {
|
||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
||||
}
|
||||
if ((cloudinaryConfig as any).logoPublicId) {
|
||||
const cloudName = (cloudinaryConfig as any).cloudName;
|
||||
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
|
||||
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
|
||||
if ((cloudinaryConfig as any).logoUrl) {
|
||||
setLogoPreview((cloudinaryConfig as any).logoUrl);
|
||||
}
|
||||
}
|
||||
}, [cloudinaryConfig]);
|
||||
@@ -460,40 +452,40 @@ export default function Settings() {
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cloud className="w-5 h-5" />
|
||||
Configuration Cloudinary
|
||||
Configuration Stockage MinIO
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configurez Cloudinary pour le stockage d'images dans le cloud
|
||||
Configurez MinIO pour le stockage des médias (endpoint configuré via variable d'environnement MINIO_ENDPOINT)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cloudName">Cloud Name</Label>
|
||||
<Label htmlFor="cloudName">Nom du bucket</Label>
|
||||
<Input
|
||||
id="cloudName"
|
||||
type="text"
|
||||
value={cloudName}
|
||||
onChange={(e) => setCloudName(e.target.value)}
|
||||
placeholder="mon-cloud-name"
|
||||
placeholder="socialflow"
|
||||
data-testid="input-cloud-name"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Trouvez-le dans votre tableau de bord Cloudinary
|
||||
Nom du bucket MinIO dans lequel stocker les fichiers
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<Label htmlFor="apiKey">Access Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="text"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="123456789012345"
|
||||
placeholder="minioadmin"
|
||||
data-testid="input-api-key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiSecret">API Secret</Label>
|
||||
<Label htmlFor="apiSecret">Secret Key</Label>
|
||||
<Input
|
||||
id="apiSecret"
|
||||
type="password"
|
||||
@@ -503,7 +495,7 @@ export default function Settings() {
|
||||
data-testid="input-api-secret"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Votre API Secret restera sécurisé et ne sera jamais exposé
|
||||
Votre Secret Key restera sécurisé et ne sera jamais exposé
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -512,7 +504,7 @@ export default function Settings() {
|
||||
data-testid="button-save-cloudinary"
|
||||
className="w-full"
|
||||
>
|
||||
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer Cloudinary"}
|
||||
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer MinIO"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -723,7 +715,7 @@ export default function Settings() {
|
||||
</div>
|
||||
{!cloudinaryConfig && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
||||
Veuillez d'abord configurer Cloudinary ci-dessus
|
||||
Veuillez d'abord configurer MinIO ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user