diff --git a/.cursor/skills/signbank-frontend/SKILL.md b/.cursor/skills/signbank-frontend/SKILL.md index 10a9557..52d7deb 100644 --- a/.cursor/skills/signbank-frontend/SKILL.md +++ b/.cursor/skills/signbank-frontend/SKILL.md @@ -62,6 +62,14 @@ Uploaded videos return relative paths from API. Display via nginx: Example: API returns `gloss-videos/abc.mp4` → src `/lscassets/gloss-videos/abc.mp4`. +Google Drive URLs stored in the DB are rewritten by `getVideoUrl()` to: + +``` +/api/videos/drive/{fileId} +``` + +The backend streams them with `GOOGLE_DRIVE_API_KEY`. Files must be shared as "Anyone with the link". + Validation helper: `src/utils/videoValidation.ts`. ## Search UI diff --git a/.cursor/skills/signbank-infrastructure/SKILL.md b/.cursor/skills/signbank-infrastructure/SKILL.md index 193e292..113fd83 100644 --- a/.cursor/skills/signbank-infrastructure/SKILL.md +++ b/.cursor/skills/signbank-infrastructure/SKILL.md @@ -13,6 +13,7 @@ Local config: `nginx/default.conf`. Production/test variants under `nginx/nginx. https://host/ → frontend:443 https://host/ws → frontend (WebSocket/HMR) https://host/api/* → backend:443 (strip /api prefix) +https://host/api/videos/drive/* → backend Drive proxy (no buffering) https://host/lscassets/* → dufs:5000 (video files) ``` diff --git a/.env.example b/.env.example index 3be52a5..e4948de 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ TYPESENSE_PORT=8108 FILE_SERVER_PATH=/production/FileServer DUFS_URL=http://dufs:5000 +# --- Google Drive videos (optional) --- +# Required to play videos stored as drive.google.com URLs in the DB. +# Create a Google Cloud API key with the Drive API enabled. Files must be +# shared as "Anyone with the link" (an API key cannot impersonate your account). +GOOGLE_DRIVE_API_KEY= + # --- JWT authentication --- JWT_SECRET=your-jwt-secret-here-min-32-chars-long JWT_EXPIRATION=24h diff --git a/backend/README.md b/backend/README.md index eb1ab54..ee50315 100644 --- a/backend/README.md +++ b/backend/README.md @@ -113,6 +113,7 @@ The following diagram illustrates the main structure of the database used by the --- ## Videos (`/videos`) +- `GET /videos/drive/:fileId` — Stream a Google Drive video (public; requires `GOOGLE_DRIVE_API_KEY`) - `POST /videos/upload` — Upload a video (JWT required) - `DELETE /videos/:videoUrl(*)` — Delete a video (JWT required) diff --git a/backend/src/videos/google-drive.util.spec.ts b/backend/src/videos/google-drive.util.spec.ts new file mode 100644 index 0000000..b6adaa6 --- /dev/null +++ b/backend/src/videos/google-drive.util.spec.ts @@ -0,0 +1,48 @@ +import { + extractGoogleDriveFileId, + extractGoogleDriveResourceKey, + isDriveFileId, + isGoogleDriveUrl, +} from './google-drive.util'; + +describe('google-drive.util', () => { + const fileId = '1f8YqJU_Lne5Az0o8gyAR1CAPKE3CbA8x'; + + it('accepts typical Drive file ids', () => { + expect(isDriveFileId(fileId)).toBe(true); + expect(isDriveFileId('short')).toBe(false); + expect(isDriveFileId('../etc/passwd')).toBe(false); + }); + + it('extracts ids from sharing links', () => { + expect( + extractGoogleDriveFileId( + `https://drive.google.com/file/d/${fileId}/view?usp=sharing`, + ), + ).toBe(fileId); + }); + + it('extracts ids from open and uc links', () => { + expect( + extractGoogleDriveFileId(`https://drive.google.com/open?id=${fileId}`), + ).toBe(fileId); + expect( + extractGoogleDriveFileId( + `https://drive.google.com/uc?export=download&id=${fileId}`, + ), + ).toBe(fileId); + }); + + it('reads resource keys', () => { + expect( + extractGoogleDriveResourceKey( + `https://drive.google.com/file/d/${fileId}/view?usp=sharing&resourcekey=abc-123`, + ), + ).toBe('abc-123'); + }); + + it('ignores non-Drive paths', () => { + expect(extractGoogleDriveFileId('gloss-videos/example.mp4')).toBeNull(); + expect(isGoogleDriveUrl('https://example.com/video.mp4')).toBe(false); + }); +}); diff --git a/backend/src/videos/google-drive.util.ts b/backend/src/videos/google-drive.util.ts new file mode 100644 index 0000000..11e997f --- /dev/null +++ b/backend/src/videos/google-drive.util.ts @@ -0,0 +1,50 @@ +const DRIVE_FILE_ID_PATTERN = /^[a-zA-Z0-9_-]{10,128}$/; + +export function isDriveFileId(fileId: string): boolean { + return DRIVE_FILE_ID_PATTERN.test(fileId); +} + +function hostLooksLikeGoogleDrive(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host.includes('google.com') || host.includes('googleusercontent.com'); +} + +export function extractGoogleDriveFileId(value: string): string | null { + if (!value?.trim()) { + return null; + } + + try { + const parsed = new URL(value.trim()); + if (!hostLooksLikeGoogleDrive(parsed.hostname)) { + return null; + } + + const fromQuery = parsed.searchParams.get('id'); + if (fromQuery && isDriveFileId(fromQuery)) { + return fromQuery; + } + + const fromPath = parsed.pathname.match(/\/(?:file\/)?d\/([^/]+)/); + if (fromPath?.[1] && isDriveFileId(fromPath[1])) { + return fromPath[1]; + } + } catch { + return null; + } + + return null; +} + +export function extractGoogleDriveResourceKey(value: string): string | null { + try { + const parsed = new URL(value.trim()); + return parsed.searchParams.get('resourcekey') || parsed.searchParams.get('resourceKey'); + } catch { + return null; + } +} + +export function isGoogleDriveUrl(value: string): boolean { + return extractGoogleDriveFileId(value) !== null; +} diff --git a/backend/src/videos/videos.controller.ts b/backend/src/videos/videos.controller.ts index f030fbe..3b5a56c 100644 --- a/backend/src/videos/videos.controller.ts +++ b/backend/src/videos/videos.controller.ts @@ -1,12 +1,13 @@ -import { Controller, Post, Delete, UploadedFile, UseInterceptors, Body, Param } from '@nestjs/common'; +import { Controller, Post, Delete, UploadedFile, UseInterceptors, Body, Param, Get, Query, Req, Res, UseGuards } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { VideosService } from './videos.service'; import { diskStorage } from 'multer'; import { extname, join } from 'path'; -import { UseGuards } from '@nestjs/common'; import { JwtGuard } from '../auth/guard/jwt.guard'; import { RolesGuard } from '../auth/guard/roles.guard'; +import { Public } from '../auth/decorator/public.decorator'; import { promises as fs } from 'fs'; +import type { Request, Response } from 'express'; @Controller('videos') @UseGuards(JwtGuard, RolesGuard) @@ -33,6 +34,17 @@ export class VideosController { } } + @Public() + @Get('drive/:fileId') + async streamDriveVideo( + @Param('fileId') fileId: string, + @Query('resourceKey') resourceKey: string | undefined, + @Req() req: Request, + @Res() res: Response, + ) { + return this.videosService.streamDriveVideo(fileId, req, res, resourceKey); + } + @Post('upload') @UseInterceptors( FileInterceptor('video', { diff --git a/backend/src/videos/videos.service.spec.ts b/backend/src/videos/videos.service.spec.ts new file mode 100644 index 0000000..63f8fb2 --- /dev/null +++ b/backend/src/videos/videos.service.spec.ts @@ -0,0 +1,58 @@ +import { HttpException } from '@nestjs/common'; +import axios from 'axios'; +import { VideosService } from './videos.service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('VideosService Drive streaming', () => { + const originalApiKey = process.env.GOOGLE_DRIVE_API_KEY; + let service: VideosService; + + beforeEach(() => { + jest.resetAllMocks(); + mockedAxios.isAxiosError = axios.isAxiosError; + process.env.GOOGLE_DRIVE_API_KEY = 'test-key'; + service = new VideosService(); + }); + + afterAll(() => { + process.env.GOOGLE_DRIVE_API_KEY = originalApiKey; + }); + + it('does not delete Google Drive files from Dufs', async () => { + await expect( + service.deleteVideo( + 'https://drive.google.com/file/d/1f8YqJU_Lne5Az0o8gyAR1CAPKE3CbA8x/view?usp=sharing', + ), + ).resolves.toBeUndefined(); + expect(mockedAxios.delete).not.toHaveBeenCalled(); + }); + + it('rejects invalid Drive file ids', async () => { + await expect( + service.streamDriveVideo( + '../nope', + { headers: {}, on: jest.fn() } as never, + {} as never, + ), + ).rejects.toBeInstanceOf(HttpException); + }); + + it('fails closed when the API key is missing', async () => { + delete process.env.GOOGLE_DRIVE_API_KEY; + service = new VideosService(); + + try { + await service.streamDriveVideo( + '1f8YqJU_Lne5Az0o8gyAR1CAPKE3CbA8x', + { headers: {}, on: jest.fn() } as never, + {} as never, + ); + throw new Error('expected streamDriveVideo to reject'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(503); + } + }); +}); diff --git a/backend/src/videos/videos.service.ts b/backend/src/videos/videos.service.ts index 00e8af1..63a3f7a 100644 --- a/backend/src/videos/videos.service.ts +++ b/backend/src/videos/videos.service.ts @@ -2,12 +2,22 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { createReadStream } from 'fs'; import { stat, unlink } from 'fs/promises'; import axios from 'axios'; -import { basename, join } from 'path'; +import { basename } from 'path'; +import type { Request, Response } from 'express'; +import { isDriveFileId, isGoogleDriveUrl } from './google-drive.util'; @Injectable() export class VideosService { private readonly dufsUrl = process.env.DUFS_URL || 'http://localhost:5000'; + constructor() { + if (!process.env.GOOGLE_DRIVE_API_KEY?.trim()) { + console.warn( + 'GOOGLE_DRIVE_API_KEY is not set; Google Drive videos will not play', + ); + } + } + async uploadVideo(file: any, type: 'gloss' | 'example' | 'definition' = 'gloss'): Promise<{ url: string }> { try { const fileStream = createReadStream(file.path); @@ -62,16 +72,122 @@ export class VideosService { async deleteVideo(videoUrl: string): Promise { console.log('Deleting video', videoUrl); + + if ( + isGoogleDriveUrl(videoUrl) || + videoUrl.startsWith('http://') || + videoUrl.startsWith('https://') + ) { + return; + } + try { // The videoUrl is already in the format "baseDir/filename" const deleteUrl = `${this.dufsUrl}/${videoUrl}`; await axios.delete(deleteUrl); - } catch (error) { + } catch { throw new HttpException( 'Failed to delete video', HttpStatus.INTERNAL_SERVER_ERROR, ); } } -} \ No newline at end of file + + async streamDriveVideo( + fileId: string, + req: Request, + res: Response, + resourceKey?: string, + ): Promise { + if (!isDriveFileId(fileId)) { + throw new HttpException('Invalid Google Drive file id', HttpStatus.BAD_REQUEST); + } + + const apiKey = process.env.GOOGLE_DRIVE_API_KEY?.trim(); + if (!apiKey) { + throw new HttpException( + 'GOOGLE_DRIVE_API_KEY is not configured', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + const driveUrl = new URL( + `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`, + ); + driveUrl.searchParams.set('alt', 'media'); + driveUrl.searchParams.set('supportsAllDrives', 'true'); + driveUrl.searchParams.set('key', apiKey); + + const headers: Record = {}; + const range = req.headers.range; + if (typeof range === 'string' && range.length > 0) { + headers.Range = range; + } + if (resourceKey) { + headers['X-Goog-Drive-Resource-Keys'] = `${fileId}/${resourceKey}`; + } + + try { + const driveResponse = await axios.get(driveUrl.toString(), { + headers, + responseType: 'stream', + timeout: 0, + maxContentLength: Infinity, + maxBodyLength: Infinity, + validateStatus: (status) => status === 200 || status === 206, + }); + + res.status(driveResponse.status); + res.setHeader( + 'Content-Type', + driveResponse.headers['content-type'] || 'video/mp4', + ); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + + const contentLength = driveResponse.headers['content-length']; + if (contentLength) { + res.setHeader('Content-Length', contentLength); + } + const contentRange = driveResponse.headers['content-range']; + if (contentRange) { + res.setHeader('Content-Range', contentRange); + } + + const stream = driveResponse.data as NodeJS.ReadableStream; + const abort = () => { + if (typeof (stream as { destroy?: () => void }).destroy === 'function') { + (stream as { destroy: () => void }).destroy(); + } + }; + req.on('close', abort); + stream.on('error', abort); + stream.pipe(res); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const data = error.response?.data as { destroy?: () => void } | undefined; + data?.destroy?.(); + + if (status === 401 || status === 403) { + throw new HttpException( + 'Google Drive denied access. Share the file with "Anyone with the link" and check GOOGLE_DRIVE_API_KEY.', + HttpStatus.FORBIDDEN, + ); + } + if (status === 404) { + throw new HttpException( + 'Google Drive file not found. Confirm the file id and that it is shared with "Anyone with the link".', + HttpStatus.NOT_FOUND, + ); + } + } + + throw new HttpException( + 'Failed to fetch video from Google Drive', + HttpStatus.BAD_GATEWAY, + ); + } + } +} diff --git a/docker-compose-local.yaml b/docker-compose-local.yaml index 2074b2b..a00725f 100644 --- a/docker-compose-local.yaml +++ b/docker-compose-local.yaml @@ -18,6 +18,7 @@ services: - TYPESENSE_PORT=${TYPESENSE_PORT} - JWT_SECRET=${JWT_SECRET} - JWT_EXPIRATION=${JWT_EXPIRATION:-24h} + - GOOGLE_DRIVE_API_KEY=${GOOGLE_DRIVE_API_KEY:-} - SEED_DB=${SEED_DB:-false} ports: - 9229:9229 diff --git a/docker-compose-production.yaml b/docker-compose-production.yaml index 1c6e7f4..03109e1 100644 --- a/docker-compose-production.yaml +++ b/docker-compose-production.yaml @@ -18,6 +18,7 @@ services: - BASE_URL=${BASE_URL:-localhost} - JWT_SECRET=${JWT_SECRET} - JWT_EXPIRATION=${JWT_EXPIRATION:-24h} + - GOOGLE_DRIVE_API_KEY=${GOOGLE_DRIVE_API_KEY:-} - ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - PRISMA_CLI_BINARY_TARGETS=linux-musl,linux-musl-openssl-3.0.x diff --git a/docker-compose-test.yaml b/docker-compose-test.yaml index f97e0d0..c6d967e 100644 --- a/docker-compose-test.yaml +++ b/docker-compose-test.yaml @@ -17,6 +17,7 @@ services: - DUFS_URL=${DUFS_URL} - JWT_SECRET=${JWT_SECRET} - JWT_EXPIRATION=${JWT_EXPIRATION:-24h} + - GOOGLE_DRIVE_API_KEY=${GOOGLE_DRIVE_API_KEY:-} - ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - BASE_URL=${BASE_URL} diff --git a/docker-compose.dockploy.yaml b/docker-compose.dockploy.yaml index a5bd5fd..b63ce46 100644 --- a/docker-compose.dockploy.yaml +++ b/docker-compose.dockploy.yaml @@ -1,9 +1,7 @@ # SignBank — Dockploy / test deployment -# No nginx container: Dockploy Traefik handles SSL and routing. -# -# Dockploy route mapping: -# / → frontend:80 -# /api/* → backend:3000 (strip /api prefix in Dockploy) +# No outer nginx container: Dockploy Traefik terminates SSL and should +# publish only the frontend (port 80). Frontend nginx then proxies: +# /api/* → backend:3000 (strips /api) # /lscassets → dufs:5000 # # Set all secrets in Dockploy env UI. Use SEED_DB=true only on first deploy. @@ -29,6 +27,7 @@ services: - BASE_URL=${BASE_URL} - JWT_SECRET=${JWT_SECRET} - JWT_EXPIRATION=${JWT_EXPIRATION:-24h} + - GOOGLE_DRIVE_API_KEY=${GOOGLE_DRIVE_API_KEY:-} - ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - BACKUP_ENABLED=false @@ -54,6 +53,9 @@ services: networks: - signbank restart: unless-stopped + depends_on: + - backend + - dufs postgres: image: postgres:16 diff --git a/docker-compose.ghcr.yaml b/docker-compose.ghcr.yaml index 77d517d..965d444 100644 --- a/docker-compose.ghcr.yaml +++ b/docker-compose.ghcr.yaml @@ -8,7 +8,8 @@ # # Make GHCR packages public, or configure a registry token in Dockploy. # -# Same routing as docker-compose.dockploy.yaml (Traefik handles SSL). +# Same routing as docker-compose.dockploy.yaml: publish frontend:80; +# frontend nginx proxies /api → backend:3000 and /lscassets → dufs:5000. networks: signbank: @@ -30,6 +31,7 @@ services: - BASE_URL=${BASE_URL} - JWT_SECRET=${JWT_SECRET} - JWT_EXPIRATION=${JWT_EXPIRATION:-24h} + - GOOGLE_DRIVE_API_KEY=${GOOGLE_DRIVE_API_KEY:-} - ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - BACKUP_ENABLED=false @@ -52,6 +54,9 @@ services: networks: - signbank restart: unless-stopped + depends_on: + - backend + - dufs postgres: image: postgres:16 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7e3d282..a801aea 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -90,9 +90,12 @@ Copy `.env.example` → `.env` and set: | `TYPESENSE_HOST` | Yes | `typesense` in Docker network | | `TYPESENSE_PORT` | Yes | `8108` | | `DUFS_URL` | Yes | `http://dufs:5000` in Docker | +| `GOOGLE_DRIVE_API_KEY` | If DB has Drive URLs | Google Cloud API key with Drive API enabled. Files must be shared as **Anyone with the link** | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | First deploy | Creates admin if none exists | | `SEED_DB` | Test only | Set `true` once for sample data, then remove | +**Google Drive videos:** create a Google Cloud API key, enable the **Google Drive API**, and set `GOOGLE_DRIVE_API_KEY`. An API key cannot use your personal Drive permissions — share the video folder as **Anyone with the link** (Viewer). Restrict the key to the Drive API (and optionally your server IPs). + ### 3. File storage Create video directories (not in git): @@ -137,6 +140,7 @@ make prod-up - [ ] Login with admin credentials works - [ ] Search returns results (after seed or accepted glosses) - [ ] Video plays from `/lscassets/gloss-videos/...` +- [ ] Google Drive videos play when `GOOGLE_DRIVE_API_KEY` is set and files are shared with "Anyone with the link" - [ ] Restart backend container → search still works ### 7. Migrations @@ -167,13 +171,15 @@ Do **not** set `SEED_DB=true` on updates — it re-runs seed data. ## Dockploy routing -When using `docker-compose.dockploy.yaml` (no nginx container): +When using `docker-compose.dockploy.yaml` (no outer nginx container), publish **only the frontend** on port 80. Frontend nginx proxies `/api` and `/lscassets` on the Docker network: | Public path | Target service | Port | |-------------|----------------|------| | `/` | frontend | 80 | -| `/api/*` | backend | 3000 (strip `/api` prefix) | -| `/lscassets/*` | dufs | 5000 | +| `/api/*` | frontend → backend | 3000 (frontend nginx strips `/api`) | +| `/lscassets/*` | frontend → dufs | 5000 | + +Do not point `/api` at the frontend without that proxy — nginx `try_files` returns **405** on POST (login). Do not also strip `/api` in Traefik if the domain already goes to the frontend; stripping twice would break routing. Set all env vars in the Dockploy project settings (never commit secrets). @@ -186,7 +192,8 @@ Set all env vars in the Dockploy project settings (never commit secrets). | Symptom | Check | |---------|-------| | Backend won't start in prod | Logs for missing `JWT_SECRET` or env validation errors | -| 502 on `/api` | Backend container running; `DATABASE_URL` correct | +| **405 on login** (`POST /api/auth/login`) | `/api` is hitting the SPA nginx, not NestJS. Rebuild frontend (it must proxy `/api/` to `backend:3000`) and publish only the frontend domain in Dockploy | +| 502 on `/api` | Backend container running; `DATABASE_URL` correct; frontend can resolve `backend` on the compose network | | Search empty after restart | Typesense bootstrap logs; admin `POST /api/typesense/sync` | | Videos 404 | `FileServer/` exists and dufs volume mounted | | Migration failed | `prisma migrate status`; see DATABASE.md | diff --git a/frontend/default.conf b/frontend/default.conf index 43c07ea..eb787c6 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -9,10 +9,52 @@ server { add_header X-XSS-Protection "1; mode=block"; add_header X-Content-Type-Options "nosniff"; add_header Referrer-Policy "strict-origin-when-cross-origin"; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://drive.google.com; media-src 'self'"; index index.html; charset utf-8; + client_max_body_size 20M; + + # Dockploy / Traefik typically publish only this container. Without these + # proxies, POST /api/* hits try_files and nginx returns 405. + location /api/videos/drive/ { + proxy_pass http://backend:3000/videos/drive/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Range $http_range; + proxy_buffering off; + proxy_request_buffering off; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location /api/ { + proxy_pass http://backend:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 180s; + proxy_send_timeout 180s; + proxy_read_timeout 180s; + } + + location /lscassets { + proxy_pass http://dufs:5000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } # Serve static files location / { diff --git a/frontend/src/components/GlossDetail/components/GlossVideoComponent.vue b/frontend/src/components/GlossDetail/components/GlossVideoComponent.vue index 7a63bac..87dab79 100644 --- a/frontend/src/components/GlossDetail/components/GlossVideoComponent.vue +++ b/frontend/src/components/GlossDetail/components/GlossVideoComponent.vue @@ -5,17 +5,17 @@ class="video-angle-container" :class="{ 'video-angle-container--compact': compact }" > -