Add automatic GIF preview generation for videos
Backend changes: - Add ffmpeg to Docker image for video processing - Create gifGenerator utility with high-quality palette-based GIF generation - Update Prisma schema: Add GIF to MediaKind enum - Auto-generate 300px GIF preview (10fps, 3sec) on video upload - Add POST /api/terms/:id/media/:mediaId/regenerate-gif endpoint for admins - Update delete endpoint to cascade delete GIFs when video is deleted - GIF generation uses ffmpeg with palette optimization for quality Frontend changes: - Update MediaKind enum to include GIF - Display animated GIF previews in dictionary word cards - Show 'No preview' icon for videos without GIF - Add 'Regenerate GIF' button in admin video upload UI - Display both video and GIF side-by-side in admin panel - Visual indicator when GIF preview is available Features: - Automatic GIF generation on video upload (non-blocking) - Manual GIF regeneration from admin panel - GIF preview in dictionary listing for better UX - Fallback to video icon when no GIF available - Proper cleanup: GIFs deleted when parent video is deleted Technical details: - GIFs stored in /uploads/gifs/ - Uses two-pass ffmpeg encoding with palette for quality - Configurable fps, width, duration, start time - Error handling: video upload succeeds even if GIF fails Co-Authored-By: Auggie
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,7 +39,7 @@ coverage/
|
||||
packages/backend/uploads/
|
||||
|
||||
# Prisma
|
||||
packages/backend/prisma/migrations/
|
||||
!packages/backend/prisma/migrations/
|
||||
!packages/backend/prisma/migrations/.gitkeep
|
||||
|
||||
# Temporary files
|
||||
|
||||
92
Dockerfile
Normal file
92
Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
||||
# ===========================================
|
||||
# Stage 1: Build Frontend
|
||||
# ===========================================
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace configuration files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
|
||||
# Install pnpm and dependencies
|
||||
RUN npm install -g pnpm && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Copy frontend source code
|
||||
COPY packages/frontend ./packages/frontend
|
||||
|
||||
# Build frontend
|
||||
RUN cd packages/frontend && pnpm build
|
||||
|
||||
# ===========================================
|
||||
# Stage 2: Build Backend
|
||||
# ===========================================
|
||||
FROM node:20-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace configuration files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
|
||||
# Install pnpm and dependencies
|
||||
RUN npm install -g pnpm && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Copy backend source code
|
||||
COPY packages/backend ./packages/backend
|
||||
|
||||
# Generate Prisma client and build backend
|
||||
RUN cd packages/backend && \
|
||||
npx prisma generate && \
|
||||
pnpm build
|
||||
|
||||
# ===========================================
|
||||
# Stage 3: Production Runtime
|
||||
# ===========================================
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for Prisma compatibility, nginx for reverse proxy, and ffmpeg for video processing
|
||||
RUN apk add --no-cache openssl nginx ffmpeg
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy workspace configuration files
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
|
||||
# Install ALL dependencies (including Prisma CLI needed for migrations)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy backend build artifacts
|
||||
COPY --from=backend-builder /app/packages/backend/dist ./packages/backend/dist
|
||||
COPY --from=backend-builder /app/packages/backend/prisma ./packages/backend/prisma
|
||||
|
||||
# Copy Prisma generated client (in pnpm workspace structure)
|
||||
# We need to copy the entire .pnpm directory to preserve the Prisma client
|
||||
COPY --from=backend-builder /app/node_modules/.pnpm ./node_modules/.pnpm
|
||||
|
||||
# Copy frontend build artifacts
|
||||
COPY --from=frontend-builder /app/packages/frontend/dist ./packages/frontend/dist
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/packages/backend/uploads
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy startup script
|
||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
# Expose frontend port
|
||||
EXPOSE 5173
|
||||
|
||||
# Set entrypoint
|
||||
CMD ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TABLE `term_media` MODIFY `kind` ENUM('VIDEO', 'IMAGE', 'ILLUSTRATION', 'GIF') NOT NULL;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["fullTextIndex"]
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -223,6 +224,7 @@ enum MediaKind {
|
||||
VIDEO
|
||||
IMAGE
|
||||
ILLUSTRATION
|
||||
GIF
|
||||
}
|
||||
|
||||
enum Visibility {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from '../lib/prisma.js';
|
||||
import { WordType, CefrLevel, MediaKind } from '@prisma/client';
|
||||
import { isAuthenticated, isAdmin } from '../middleware/auth.js';
|
||||
import { videoUpload, handleUploadError } from '../middleware/upload.js';
|
||||
import { generateGifFromVideo } from '../utils/gifGenerator.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -350,7 +351,7 @@ router.post(
|
||||
// Create relative URL path
|
||||
const relativeUrl = `/uploads/videos/${req.file.filename}`;
|
||||
|
||||
// Create TermMedia record
|
||||
// Create TermMedia record for video
|
||||
const media = await prisma.termMedia.create({
|
||||
data: {
|
||||
termId: id,
|
||||
@@ -361,6 +362,43 @@ router.post(
|
||||
},
|
||||
});
|
||||
|
||||
// Generate GIF preview from video
|
||||
try {
|
||||
const videoPath = req.file.path;
|
||||
const gifFilename = req.file.filename.replace(/\.(mp4|webm|mov)$/i, '.gif');
|
||||
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
||||
|
||||
// Ensure gifs directory exists
|
||||
if (!fs.existsSync(gifsDir)) {
|
||||
fs.mkdirSync(gifsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const gifPath = path.join(gifsDir, gifFilename);
|
||||
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
|
||||
|
||||
// Generate GIF (300px width, 10fps, first 3 seconds)
|
||||
await generateGifFromVideo(videoPath, gifPath, {
|
||||
width: 300,
|
||||
fps: 10,
|
||||
duration: 3,
|
||||
startTime: 0,
|
||||
});
|
||||
|
||||
// Create TermMedia record for GIF
|
||||
await prisma.termMedia.create({
|
||||
data: {
|
||||
termId: id,
|
||||
kind: MediaKind.GIF,
|
||||
url: gifRelativeUrl,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Generated GIF preview: ${gifRelativeUrl}`);
|
||||
} catch (gifError: any) {
|
||||
// Log error but don't fail the upload if GIF generation fails
|
||||
console.error('Failed to generate GIF preview:', gifError.message);
|
||||
}
|
||||
|
||||
res.status(201).json({ media });
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading video:', error);
|
||||
@@ -414,6 +452,51 @@ router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Reque
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete video file ${filename}:`, err);
|
||||
}
|
||||
|
||||
// Also delete corresponding GIF if it exists
|
||||
const gifFilename = filename.replace(/\.(mp4|webm|mov)$/i, '.gif');
|
||||
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
||||
const gifPath = path.join(gifsDir, gifFilename);
|
||||
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(gifPath)) {
|
||||
fs.unlinkSync(gifPath);
|
||||
console.log(`Deleted GIF file: ${gifFilename}`);
|
||||
}
|
||||
|
||||
// Delete GIF media record
|
||||
const gifMedia = await prisma.termMedia.findFirst({
|
||||
where: {
|
||||
termId: id,
|
||||
kind: MediaKind.GIF,
|
||||
url: gifRelativeUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (gifMedia) {
|
||||
await prisma.termMedia.delete({ where: { id: gifMedia.id } });
|
||||
console.log(`Deleted GIF media record: ${gifMedia.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete GIF ${gifFilename}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete GIF file from disk if it's a GIF
|
||||
if (media.kind === MediaKind.GIF && media.url) {
|
||||
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
||||
const filename = path.basename(media.url);
|
||||
const filePath = path.join(gifsDir, filename);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`Deleted GIF file: ${filename}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete GIF file ${filename}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete TermMedia record
|
||||
@@ -426,5 +509,93 @@ router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Reque
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/terms/:id/media/:mediaId/regenerate-gif
|
||||
* Regenerate GIF preview for a video (Admin only)
|
||||
*/
|
||||
router.post('/:id/media/:mediaId/regenerate-gif', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, mediaId } = req.params;
|
||||
|
||||
// Check if media exists and belongs to the term
|
||||
const media = await prisma.termMedia.findUnique({
|
||||
where: { id: mediaId },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
return res.status(404).json({ error: 'Media not found' });
|
||||
}
|
||||
|
||||
if (media.termId !== id) {
|
||||
return res.status(400).json({ error: 'Media does not belong to this term' });
|
||||
}
|
||||
|
||||
if (media.kind !== MediaKind.VIDEO) {
|
||||
return res.status(400).json({ error: 'Can only regenerate GIF from video media' });
|
||||
}
|
||||
|
||||
// Get video file path
|
||||
const videoFilename = path.basename(media.url);
|
||||
const videoPath = path.join(__dirname, '..', '..', 'uploads', 'videos', videoFilename);
|
||||
|
||||
if (!fs.existsSync(videoPath)) {
|
||||
return res.status(404).json({ error: 'Video file not found on disk' });
|
||||
}
|
||||
|
||||
// Generate GIF filename and path
|
||||
const gifFilename = videoFilename.replace(/\.(mp4|webm|mov)$/i, '.gif');
|
||||
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
||||
|
||||
// Ensure gifs directory exists
|
||||
if (!fs.existsSync(gifsDir)) {
|
||||
fs.mkdirSync(gifsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const gifPath = path.join(gifsDir, gifFilename);
|
||||
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
|
||||
|
||||
// Delete old GIF if it exists
|
||||
if (fs.existsSync(gifPath)) {
|
||||
fs.unlinkSync(gifPath);
|
||||
}
|
||||
|
||||
// Delete old GIF media record if it exists
|
||||
const existingGif = await prisma.termMedia.findFirst({
|
||||
where: {
|
||||
termId: id,
|
||||
kind: MediaKind.GIF,
|
||||
url: gifRelativeUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingGif) {
|
||||
await prisma.termMedia.delete({ where: { id: existingGif.id } });
|
||||
}
|
||||
|
||||
// Generate new GIF (300px width, 10fps, first 3 seconds)
|
||||
await generateGifFromVideo(videoPath, gifPath, {
|
||||
width: 300,
|
||||
fps: 10,
|
||||
duration: 3,
|
||||
startTime: 0,
|
||||
});
|
||||
|
||||
// Create new TermMedia record for GIF
|
||||
const gifMedia = await prisma.termMedia.create({
|
||||
data: {
|
||||
termId: id,
|
||||
kind: MediaKind.GIF,
|
||||
url: gifRelativeUrl,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Regenerated GIF preview: ${gifRelativeUrl}`);
|
||||
res.status(201).json({ media: gifMedia });
|
||||
} catch (error: any) {
|
||||
console.error('Error regenerating GIF:', error);
|
||||
res.status(500).json({ error: 'Failed to regenerate GIF', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
87
packages/backend/src/utils/gifGenerator.ts
Normal file
87
packages/backend/src/utils/gifGenerator.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface GifOptions {
|
||||
fps?: number; // Frames per second (default: 10)
|
||||
width?: number; // Width in pixels (default: 300)
|
||||
duration?: number; // Duration in seconds to capture (default: 3)
|
||||
startTime?: number; // Start time in seconds (default: 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a GIF from a video file using ffmpeg
|
||||
* @param videoPath - Absolute path to the video file
|
||||
* @param outputPath - Absolute path where GIF should be saved
|
||||
* @param options - GIF generation options
|
||||
* @returns Promise<string> - Path to generated GIF
|
||||
*/
|
||||
export async function generateGifFromVideo(
|
||||
videoPath: string,
|
||||
outputPath: string,
|
||||
options: GifOptions = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
fps = 10,
|
||||
width = 300,
|
||||
duration = 3,
|
||||
startTime = 0,
|
||||
} = options;
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Build ffmpeg command
|
||||
// -ss: start time
|
||||
// -t: duration
|
||||
// -vf: video filters (fps, scale, palettegen/paletteuse for better quality)
|
||||
const paletteFile = path.join(outputDir, `palette-${Date.now()}.png`);
|
||||
|
||||
try {
|
||||
// Step 1: Generate color palette for better GIF quality
|
||||
const paletteCmd = `ffmpeg -ss ${startTime} -t ${duration} -i "${videoPath}" -vf "fps=${fps},scale=${width}:-1:flags=lanczos,palettegen" -y "${paletteFile}"`;
|
||||
await execAsync(paletteCmd);
|
||||
|
||||
// Step 2: Generate GIF using the palette
|
||||
const gifCmd = `ffmpeg -ss ${startTime} -t ${duration} -i "${videoPath}" -i "${paletteFile}" -lavfi "fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse" -y "${outputPath}"`;
|
||||
await execAsync(gifCmd);
|
||||
|
||||
// Clean up palette file
|
||||
if (fs.existsSync(paletteFile)) {
|
||||
fs.unlinkSync(paletteFile);
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
} catch (error: any) {
|
||||
// Clean up on error
|
||||
if (fs.existsSync(paletteFile)) {
|
||||
fs.unlinkSync(paletteFile);
|
||||
}
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
throw new Error(`Failed to generate GIF: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video duration in seconds using ffprobe
|
||||
* @param videoPath - Absolute path to the video file
|
||||
* @returns Promise<number> - Duration in seconds
|
||||
*/
|
||||
export async function getVideoDuration(videoPath: string): Promise<number> {
|
||||
try {
|
||||
const cmd = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
return parseFloat(stdout.trim());
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to get video duration: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, DragEvent } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Upload, Trash2, X } from 'lucide-react';
|
||||
import { Upload, Trash2, X, RefreshCw } from 'lucide-react';
|
||||
import api from '../../lib/api';
|
||||
import { Term, MediaKind } from '../../types/term';
|
||||
import { toast } from 'sonner';
|
||||
@@ -19,9 +19,11 @@ export function VideoUpload({ term, onSuccess, onCancel }: VideoUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [regeneratingGif, setRegeneratingGif] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const existingVideos = term.media?.filter(m => m.kind === MediaKind.VIDEO) || [];
|
||||
const existingGifs = term.media?.filter(m => m.kind === MediaKind.GIF) || [];
|
||||
|
||||
// Construct full video URL
|
||||
const API_URL = import.meta.env.VITE_API_URL !== undefined ? import.meta.env.VITE_API_URL : 'http://localhost:3000';
|
||||
@@ -112,36 +114,79 @@ export function VideoUpload({ term, onSuccess, onCancel }: VideoUploadProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateGif = async (videoMediaId: string) => {
|
||||
setRegeneratingGif(videoMediaId);
|
||||
try {
|
||||
await api.post(`/api/terms/${term.id}/media/${videoMediaId}/regenerate-gif`);
|
||||
toast.success('GIF uspješno regeneriran');
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Greška pri regeneraciji GIF-a');
|
||||
} finally {
|
||||
setRegeneratingGif(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Existing Videos */}
|
||||
{existingVideos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Postojeći videozapisi</h3>
|
||||
{existingVideos.map((media) => (
|
||||
<div key={media.id} className="flex items-center gap-4 p-4 border rounded-lg">
|
||||
<video
|
||||
src={getVideoUrl(media.url)}
|
||||
className="w-48 h-32 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600">{media.url}</p>
|
||||
{media.durationMs && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Trajanje: {(media.durationMs / 1000).toFixed(1)}s
|
||||
</p>
|
||||
)}
|
||||
{existingVideos.map((media) => {
|
||||
// Find corresponding GIF for this video
|
||||
const gifFilename = media.url.replace(/\.(mp4|webm|mov)$/i, '.gif').replace('/uploads/videos/', '/uploads/gifs/');
|
||||
const correspondingGif = existingGifs.find(g => g.url === gifFilename);
|
||||
|
||||
return (
|
||||
<div key={media.id} className="flex items-center gap-4 p-4 border rounded-lg">
|
||||
<div className="flex gap-2">
|
||||
<video
|
||||
src={getVideoUrl(media.url)}
|
||||
className="w-48 h-32 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
{correspondingGif && (
|
||||
<img
|
||||
src={getVideoUrl(correspondingGif.url)}
|
||||
alt="GIF preview"
|
||||
className="w-48 h-32 object-cover rounded border-2 border-orange-500"
|
||||
title="GIF preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600">{media.url}</p>
|
||||
{media.durationMs && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Trajanje: {(media.durationMs / 1000).toFixed(1)}s
|
||||
</p>
|
||||
)}
|
||||
{correspondingGif && (
|
||||
<p className="text-xs text-green-600 mt-1">✓ GIF preview dostupan</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRegenerateGif(media.id)}
|
||||
disabled={regeneratingGif === media.id}
|
||||
title="Regeneriraj GIF preview"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${regeneratingGif === media.id ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVideo(media.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVideo(media.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Info, Plus, Video } from 'lucide-react';
|
||||
import { Term, CefrLevel } from '../../types/term';
|
||||
import { Button } from '../ui/button';
|
||||
import { wordTypeColors, wordTypeLabels } from '../../lib/wordTypeColors';
|
||||
@@ -20,6 +20,7 @@ const cefrColors: Record<CefrLevel, string> = {
|
||||
|
||||
export function WordCard({ term, onInfo, onAddToSentence }: WordCardProps) {
|
||||
const videoMedia = term.media?.find(m => m.kind === 'VIDEO');
|
||||
const gifMedia = term.media?.find(m => m.kind === 'GIF');
|
||||
const imageMedia = term.media?.find(m => m.kind === 'IMAGE' || m.kind === 'ILLUSTRATION');
|
||||
|
||||
return (
|
||||
@@ -34,17 +35,25 @@ export function WordCard({ term, onInfo, onAddToSentence }: WordCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Icon/Image */}
|
||||
{/* Icon/Image/GIF Preview */}
|
||||
<div className="flex-1 flex items-center justify-center mb-4 min-h-[120px]">
|
||||
{imageMedia ? (
|
||||
<img
|
||||
src={imageMedia.url}
|
||||
{gifMedia ? (
|
||||
<img
|
||||
src={gifMedia.url}
|
||||
alt={term.wordText}
|
||||
className="max-h-[120px] max-w-full object-contain rounded"
|
||||
title="Video preview"
|
||||
/>
|
||||
) : imageMedia ? (
|
||||
<img
|
||||
src={imageMedia.url}
|
||||
alt={term.wordText}
|
||||
className="max-h-[120px] max-w-full object-contain"
|
||||
/>
|
||||
) : videoMedia ? (
|
||||
<div className="w-full h-[120px] bg-gray-100 rounded flex items-center justify-center">
|
||||
<span className="text-gray-400 text-sm">Video</span>
|
||||
<div className="w-full h-[120px] bg-gray-100 rounded flex flex-col items-center justify-center gap-2">
|
||||
<Video className="h-8 w-8 text-gray-400" />
|
||||
<span className="text-gray-400 text-xs">Nema pregleda</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-[120px] bg-gray-100 rounded flex items-center justify-center">
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum MediaKind {
|
||||
VIDEO = 'VIDEO',
|
||||
IMAGE = 'IMAGE',
|
||||
ILLUSTRATION = 'ILLUSTRATION',
|
||||
GIF = 'GIF',
|
||||
}
|
||||
|
||||
export interface TermMedia {
|
||||
|
||||
Reference in New Issue
Block a user