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
243 lines
6.5 KiB
Plaintext
243 lines
6.5 KiB
Plaintext
// prisma/schema.prisma
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
previewFeatures = ["fullTextIndex"]
|
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
|
}
|
|
|
|
datasource db {
|
|
provider = "mysql"
|
|
url = env("DATABASE_URL")
|
|
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
|
|
}
|
|
|
|
// ============================================
|
|
// AUTHENTICATION & USERS
|
|
// ============================================
|
|
|
|
enum UserRole {
|
|
ADMIN
|
|
USER
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
email String @unique
|
|
displayName String? @map("display_name")
|
|
passwordHash String? @map("password_hash")
|
|
role UserRole @default(USER)
|
|
isActive Boolean @default(true) @map("is_active")
|
|
authProvider String @default("local") @map("auth_provider") // local, google, microsoft
|
|
providerId String? @map("provider_id")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
documents Document[]
|
|
comments Comment[]
|
|
bugReports BugReport[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
// ============================================
|
|
// DICTIONARY / TERMS
|
|
// ============================================
|
|
|
|
model Term {
|
|
id String @id @default(uuid())
|
|
wordText String @map("word_text") @db.VarChar(255)
|
|
normalizedText String @map("normalized_text") @db.VarChar(255)
|
|
language String @default("hr") @db.VarChar(10)
|
|
wordType WordType @map("word_type")
|
|
cefrLevel CefrLevel @map("cefr_level")
|
|
shortDescription String? @map("short_description") @db.Text
|
|
tags String? @db.Text // JSON array stored as text
|
|
iconAssetId String? @map("icon_asset_id")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
media TermMedia[]
|
|
examples TermExample[]
|
|
sentenceTokens SentenceToken[]
|
|
|
|
@@index([normalizedText])
|
|
@@fulltext([wordText, normalizedText])
|
|
@@map("terms")
|
|
}
|
|
|
|
model TermMedia {
|
|
id String @id @default(uuid())
|
|
termId String @map("term_id")
|
|
kind MediaKind
|
|
url String @db.VarChar(500)
|
|
durationMs Int? @map("duration_ms")
|
|
width Int?
|
|
height Int?
|
|
checksum String? @db.VarChar(64)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
term Term @relation(fields: [termId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([termId])
|
|
@@map("term_media")
|
|
}
|
|
|
|
model TermExample {
|
|
id String @id @default(uuid())
|
|
termId String @map("term_id")
|
|
exampleText String @map("example_text") @db.Text
|
|
notes String? @db.Text
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
term Term @relation(fields: [termId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([termId])
|
|
@@map("term_examples")
|
|
}
|
|
|
|
// ============================================
|
|
// DOCUMENTS & SENTENCES (ZNAKOPIS)
|
|
// ============================================
|
|
|
|
model Document {
|
|
id String @id @default(uuid())
|
|
ownerUserId String? @map("owner_user_id")
|
|
title String @db.VarChar(255)
|
|
description String? @db.Text
|
|
visibility Visibility @default(PRIVATE)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
|
|
pages DocumentPage[]
|
|
|
|
@@index([ownerUserId])
|
|
@@map("documents")
|
|
}
|
|
|
|
model DocumentPage {
|
|
id String @id @default(uuid())
|
|
documentId String @map("document_id")
|
|
pageIndex Int @map("page_index")
|
|
title String? @db.VarChar(255)
|
|
|
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
|
sentences Sentence[]
|
|
|
|
@@unique([documentId, pageIndex])
|
|
@@index([documentId])
|
|
@@map("document_pages")
|
|
}
|
|
|
|
model Sentence {
|
|
id String @id @default(uuid())
|
|
documentPageId String @map("document_page_id")
|
|
sentenceIndex Int @map("sentence_index")
|
|
rawText String? @map("raw_text") @db.Text
|
|
|
|
documentPage DocumentPage @relation(fields: [documentPageId], references: [id], onDelete: Cascade)
|
|
tokens SentenceToken[]
|
|
|
|
@@unique([documentPageId, sentenceIndex])
|
|
@@index([documentPageId])
|
|
@@map("sentences")
|
|
}
|
|
|
|
model SentenceToken {
|
|
id String @id @default(uuid())
|
|
sentenceId String @map("sentence_id")
|
|
tokenIndex Int @map("token_index")
|
|
termId String? @map("term_id")
|
|
displayText String @map("display_text") @db.VarChar(255)
|
|
isPunctuation Boolean @default(false) @map("is_punctuation")
|
|
|
|
sentence Sentence @relation(fields: [sentenceId], references: [id], onDelete: Cascade)
|
|
term Term? @relation(fields: [termId], references: [id], onDelete: SetNull)
|
|
|
|
@@unique([sentenceId, tokenIndex])
|
|
@@index([sentenceId])
|
|
@@index([termId])
|
|
@@map("sentence_tokens")
|
|
}
|
|
|
|
// ============================================
|
|
// COMMUNITY & SUPPORT
|
|
// ============================================
|
|
|
|
model Comment {
|
|
id String @id @default(uuid())
|
|
userId String @map("user_id")
|
|
content String @db.Text
|
|
context String? @db.VarChar(255) // e.g., "term:uuid" or "general"
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("comments")
|
|
}
|
|
|
|
model BugReport {
|
|
id String @id @default(uuid())
|
|
userId String? @map("user_id")
|
|
title String @db.VarChar(255)
|
|
description String @db.Text
|
|
status BugStatus @default(OPEN)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([userId])
|
|
@@index([status])
|
|
@@map("bug_reports")
|
|
}
|
|
|
|
// ============================================
|
|
// ENUMS
|
|
// ============================================
|
|
|
|
enum WordType {
|
|
NOUN
|
|
VERB
|
|
ADJECTIVE
|
|
ADVERB
|
|
PRONOUN
|
|
PREPOSITION
|
|
CONJUNCTION
|
|
INTERJECTION
|
|
PHRASE
|
|
OTHER
|
|
}
|
|
|
|
enum CefrLevel {
|
|
A1
|
|
A2
|
|
B1
|
|
B2
|
|
C1
|
|
C2
|
|
}
|
|
|
|
enum MediaKind {
|
|
VIDEO
|
|
IMAGE
|
|
ILLUSTRATION
|
|
GIF
|
|
}
|
|
|
|
enum Visibility {
|
|
PRIVATE
|
|
SHARED
|
|
PUBLIC
|
|
}
|
|
|
|
enum BugStatus {
|
|
OPEN
|
|
IN_PROGRESS
|
|
RESOLVED
|
|
CLOSED
|
|
}
|
|
|