1668 lines
42 KiB
Markdown

# Public Video Gallery
## Overview
The Public Video Gallery provides a visitor-friendly interface for browsing and watching shared videos without requiring authentication. Built with category-based organization, reaction systems, and view tracking, it transforms the admin video library into a public-facing media platform similar to YouTube or Vimeo.
**Key Features:**
- **Public Access** — No login required, SEO-friendly URLs
- **Category Organization** — Browse by Entertainment, Education, Sports, News, etc.
- **Lock/Unlock System** — Admins control which videos are public via Shared Media page
- **Reaction System** — 6 emoji reactions (Like, Love, Laugh, Surprise, Sad, Angry)
- **Comment System** — Visitor comments with name/email (moderation pending)
- **View Tracking** — Track total views + watch time per video
- **Upvote System** — Visitors upvote favorite videos (ranking algorithm)
- **Related Videos** — Show 3 similar videos below player
- **Responsive Design** — Mobile-friendly grid layout
- **Video Player** — HTML5 player with controls, fullscreen, playback speed
- **Social Sharing** — Share video URLs on social media
**Access Control:**
- **Public Routes** — No authentication required
- **Admin Control** — Shared Media page (SUPER_ADMIN only) controls which videos are public
- **Unlocking Videos** — Removes from public gallery (not deleted, just hidden)
**Technology Stack:**
- **Frontend:** React + Ant Design + react-player
- **Backend:** Fastify media API public routes (no auth)
- **Caching:** Redis for public video lists (5 min TTL)
- **SEO:** Server-side meta tags, sitemap generation
---
## Architecture
```mermaid
flowchart TB
subgraph "Public Users"
U1[Desktop Browser]
U2[Mobile Browser]
U3[Social Media Bot]
end
subgraph "Admin Control"
A1[Admin User]
A2[SharedMediaPage]
end
subgraph "Public Routes (No Auth)"
P1[GET /api/public/media]
P2[GET /api/public/media/:id]
P3[POST /api/public/media/:id/view]
P4[POST /api/public/media/:id/reaction]
P5[POST /api/public/media/:id/comment]
end
subgraph "Admin Routes (Auth)"
A3[PUT /api/media/videos/:id/share]
A4[PUT /api/media/videos/:id/unshare]
end
subgraph "Database"
D1[(videos table)]
D2[(reactions table)]
D3[(comments table)]
D4[(view_logs table)]
end
subgraph "Cache"
C1[Redis<br/>Public Videos<br/>5 min TTL]
end
U1 --> P1
U2 --> P1
U3 --> P1
U1 --> P2
U2 --> P2
U1 --> P3
U1 --> P4
U1 --> P5
A1 --> A2
A2 --> A3
A2 --> A4
P1 --> C1
C1 --> D1
P2 --> D1
P3 --> D4
P4 --> D2
P5 --> D3
A3 --> D1
A4 --> D1
style P1 fill:#2ecc71
style P2 fill:#2ecc71
style C1 fill:#e74c3c
style A2 fill:#3498db
```
**Workflow:**
1. **Admin Shares Video** — Admin clicks "Share" button on SharedMediaPage → video marked public
2. **Public Browse** — Visitor navigates to /media → sees grid of public videos
3. **Video Player** — Visitor clicks video card → opens /media/:id → player page
4. **Engagement** — Visitor reacts, comments, or shares video
5. **View Tracking** — Frontend tracks watch time, sends to API on pause/end
6. **Related Videos** — API suggests 3 similar videos (same category/creator)
---
## Database Models
### Videos Table (Public Fields)
```typescript
// Only expose public-safe fields
interface PublicVideo {
id: string;
title: string;
producer: string;
creator: string;
durationSeconds: number;
quality: string;
orientation: string;
thumbnailPath: string;
publicViewCount: number;
publicUpvoteCount: number;
createdAt: Date;
// Derived fields
category: string; // From tags or directoryType
isPublic: boolean; // Computed: movedFromPublicAt === null
}
```
**Privacy:** Never expose `path`, `filename`, `fileHash`, or internal metadata publicly.
---
### Reactions Table
```sql
CREATE TABLE video_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id UUID NOT NULL REFERENCES videos(id),
reaction_type TEXT NOT NULL, -- like|love|laugh|surprise|sad|angry
session_id TEXT NOT NULL, -- IP hash or session cookie
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(video_id, session_id) -- One reaction per user per video
);
CREATE INDEX idx_reactions_video ON video_reactions(video_id);
CREATE INDEX idx_reactions_session ON video_reactions(session_id);
```
**Reaction Types:**
- 👍 `like` — General approval
- ❤️ `love` — Strong positive emotion
- 😂 `laugh` — Funny/amusing
- 😮 `surprise` — Surprising/shocking
- 😢 `sad` — Sad/emotional
- 😠 `angry` — Frustrating/angering
**Session Tracking:**
```typescript
// Use IP hash for anonymous users
const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
// Or use cookie for persistent tracking
const sessionId = req.cookies.sessionId || randomUUID();
res.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 }); // 1 year
```
---
### Comments Table
```sql
CREATE TABLE video_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id UUID NOT NULL REFERENCES videos(id),
name TEXT NOT NULL,
email TEXT, -- Optional, for moderation notifications
comment TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE, -- Moderation flag
session_id TEXT, -- For tracking duplicate comments
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_comments_video ON video_comments(video_id);
CREATE INDEX idx_comments_approved ON video_comments(approved);
```
**Moderation Workflow:**
1. User submits comment → stored with `approved = false`
2. Admin reviews comment in moderation dashboard
3. Admin clicks "Approve" → `approved = true`, comment visible
4. Admin clicks "Reject" → comment remains hidden or deleted
---
### View Logs Table
```sql
CREATE TABLE video_view_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id UUID NOT NULL REFERENCES videos(id),
session_id TEXT NOT NULL,
watch_time_seconds INTEGER DEFAULT 0, -- Actual watch time (not video duration)
completed BOOLEAN DEFAULT FALSE, -- Watched > 90%
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_view_logs_video ON video_view_logs(video_id);
CREATE INDEX idx_view_logs_session ON video_view_logs(session_id, video_id);
```
**Watch Time Tracking:**
```typescript
// Frontend sends watch time on pause/end
let watchTime = 0;
const interval = setInterval(() => {
if (!player.paused) {
watchTime++;
}
}, 1000);
// On pause or end
const handlePause = async () => {
await axios.post(`/api/public/media/${videoId}/view`, {
watchTimeSeconds: watchTime,
completed: watchTime >= video.durationSeconds * 0.9,
});
};
```
---
## API Endpoints (Public)
All endpoints are **public** (no authentication required).
### List Public Videos
```http
GET /api/public/media
```
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | number | 1 | Page number |
| `limit` | number | 24 | Results per page |
| `category` | string | - | Filter by category |
| `orientation` | string | - | Filter by orientation (portrait/landscape/square) |
| `quality` | string | - | Filter by quality (SD/HD/FHD/UHD) |
| `sort` | string | recent | Sort by: recent, popular, trending |
**Response:**
```json
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Amazing Sports Highlight",
"producer": "Studio A",
"creator": "Director B",
"durationSeconds": 125,
"quality": "FHD",
"orientation": "landscape",
"thumbnailPath": "/media/thumbnails/550e8400.jpg",
"publicViewCount": 1250,
"publicUpvoteCount": 85,
"category": "Sports",
"createdAt": "2026-02-10T12:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 24,
"total": 156,
"totalPages": 7
}
}
```
**Caching:**
```typescript
// Cache public video lists for 5 minutes
const cacheKey = `public:videos:${JSON.stringify(query)}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return reply.send(JSON.parse(cached));
}
// Fetch from database
const videos = await db.select()...;
// Cache for 5 minutes
await redisClient.setex(cacheKey, 300, JSON.stringify(videos));
```
---
### Get Video Details
```http
GET /api/public/media/:id
```
**Response:**
```json
{
"video": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Amazing Sports Highlight",
"producer": "Studio A",
"creator": "Director B",
"durationSeconds": 125,
"quality": "FHD",
"orientation": "landscape",
"width": 1920,
"height": 1080,
"thumbnailPath": "/media/thumbnails/550e8400.jpg",
"publicViewCount": 1251,
"publicUpvoteCount": 85,
"category": "Sports",
"createdAt": "2026-02-10T12:00:00Z",
"reactions": {
"like": 45,
"love": 20,
"laugh": 10,
"surprise": 5,
"sad": 3,
"angry": 2
}
},
"relatedVideos": [
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"title": "Another Sports Video",
"thumbnailPath": "/media/thumbnails/660e8400.jpg",
"durationSeconds": 90
},
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"title": "Top Plays Compilation",
"thumbnailPath": "/media/thumbnails/770e8400.jpg",
"durationSeconds": 180
}
],
"comments": [
{
"id": "880e8400-e29b-41d4-a716-446655440003",
"name": "John Doe",
"comment": "Amazing video!",
"createdAt": "2026-02-12T14:30:00Z"
}
]
}
```
**Related Videos Algorithm:**
```typescript
// Find 3 similar videos
const relatedVideos = await db.select()
.from(videos)
.where(
and(
eq(videos.isPublic, true),
eq(videos.category, video.category), // Same category
not(eq(videos.id, video.id)) // Not current video
)
)
.orderBy(desc(videos.publicViewCount)) // Most popular first
.limit(3);
```
---
### Track Video View
```http
POST /api/public/media/:id/view
```
**Request Body:**
```json
{
"watchTimeSeconds": 120,
"completed": true
}
```
**Response:**
```json
{
"success": true,
"newViewCount": 1252
}
```
**Process:**
1. Get session ID (IP hash or cookie)
2. Check if already viewed in last 24 hours (prevent duplicate counting)
3. Create view log record
4. Increment video `publicViewCount`
5. Return new view count
---
### Add/Update Reaction
```http
POST /api/public/media/:id/reaction
```
**Request Body:**
```json
{
"reactionType": "like"
}
```
**Response:**
```json
{
"success": true,
"reactions": {
"like": 46,
"love": 20,
"laugh": 10,
"surprise": 5,
"sad": 3,
"angry": 2
}
}
```
**Process:**
1. Get session ID
2. Check if user already reacted
3. If same reaction, remove it (toggle off)
4. If different reaction, update it
5. If no reaction, insert new one
6. Return updated reaction counts
---
### Submit Comment
```http
POST /api/public/media/:id/comment
```
**Request Body:**
```json
{
"name": "John Doe",
"email": "john@example.com",
"comment": "This video is amazing! Thanks for sharing."
}
```
**Response:**
```json
{
"success": true,
"message": "Comment submitted for moderation"
}
```
**Validation:**
- Name: 1-100 characters
- Email: Optional, valid email format
- Comment: 1-1000 characters, no HTML allowed
**Anti-Spam:**
- Rate limit: 5 comments per hour per session
- Duplicate detection: reject if same comment in last 24 hours
---
## Admin Workflow
### Sharing Videos (Making Public)
1. Navigate to **Media → Shared Media** page
2. Table shows all videos with "Public" toggle switch
3. **To share video:**
- Click toggle switch to ON (blue)
- Video immediately appears in public gallery
- Modal prompts for category selection (optional)
4. **To unshare video:**
- Click toggle switch to OFF (grey)
- Video removed from public gallery
- `movedFromPublicAt` timestamp set (preserves history)
**Shared Media Page Features:**
- **Category Management** — Assign videos to categories (Entertainment, Education, Sports, etc.)
- **Bulk Actions** — Select multiple videos, share/unshare all at once
- **Preview** — Click "Preview" button to see public view
- **Stats** — View count, upvote count, reaction breakdown
- **Lock Indicator** — Icon shows which videos are currently public
---
### Setting Categories
**Option 1: Tag-Based Categories**
Use video tags to auto-assign categories:
```typescript
// If video has "sports" tag → Sports category
// If video has "education" or "tutorial" tag → Education category
const detectCategory = (tags: string[]): string => {
if (tags.some(t => ['sports', 'game', 'play'].includes(t.toLowerCase()))) {
return 'Sports';
}
if (tags.some(t => ['education', 'tutorial', 'learn'].includes(t.toLowerCase()))) {
return 'Education';
}
if (tags.some(t => ['entertainment', 'comedy', 'music'].includes(t.toLowerCase()))) {
return 'Entertainment';
}
return 'Other';
};
```
**Option 2: Manual Assignment**
1. Select video in Shared Media page
2. Click "Edit Category" button
3. Modal opens with category dropdown:
- Entertainment
- Education
- Sports
- News
- Music
- Gaming
- Science & Tech
- Travel
- Other
4. Click "Save"
5. Category updated immediately
---
### Viewing Statistics
**Per-Video Stats:**
1. Click video row in Shared Media page
2. Stats drawer slides in from right showing:
- **Total Views** — All-time view count
- **Average Watch Time** — Mean watch time (seconds)
- **Completion Rate** — % of viewers who watched > 90%
- **Upvotes** — Total upvote count
- **Reactions Breakdown** — Chart showing reaction distribution
- **Top Referrers** — Where views came from (direct, social, etc.)
- **View Trend** — Line chart of views over last 30 days
**Gallery-Wide Stats:**
Dashboard widget showing:
- Total public videos
- Total views across all videos
- Most popular video (by views)
- Trending video (highest growth rate)
- Total reactions
- Total comments (pending + approved)
---
### Moderating Comments
1. Navigate to **Media → Comments** page (or notification badge in sidebar)
2. Table shows all comments with filters:
- **Pending** — Awaiting moderation
- **Approved** — Visible on public gallery
- **Rejected** — Hidden from public
3. **To approve comment:**
- Click "Approve" button
- Comment appears on video page immediately
4. **To reject comment:**
- Click "Reject" button
- Comment hidden (or deleted)
- Optional: Send email to commenter explaining why
**Bulk Moderation:**
- Select multiple comments via checkboxes
- Click "Approve All" or "Reject All"
- Batch updates applied instantly
---
## Public User Workflow
### Browsing Gallery
1. Navigate to **https://cmlite.org/media**
2. Hero section shows featured video (most popular or admin-selected)
3. Category tabs below hero:
- All
- Entertainment
- Education
- Sports
- News
- Music
- Gaming
- Science & Tech
4. Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile)
5. Each card shows:
- Thumbnail image
- Title
- Producer/creator
- Duration badge
- View count
- Quality badge (HD, FHD, UHD)
**Infinite Scroll:**
- As user scrolls to bottom, next page loads automatically
- Loading spinner shows while fetching
- No "Load More" button needed
---
### Watching Video
1. Click video card → navigates to **https://cmlite.org/media/:id**
2. Video player page layout:
- **Video Player** — Full-width HTML5 player with controls
- **Video Title & Metadata** — Title, producer, creator, view count
- **Reaction Bar** — 6 emoji buttons with counts
- **Description** — Auto-generated or admin-provided
- **Comments Section** — Approved comments + submit form
- **Related Videos** — 3 similar videos in sidebar
3. User clicks play → video starts, watch time tracked
4. User clicks reaction → emoji highlighted, count increments
5. User scrolls to comments → reads existing, submits new
**Video Player Features:**
- Play/pause button
- Volume slider
- Playback speed (0.5x, 1x, 1.25x, 1.5x, 2x)
- Fullscreen button
- Current time / total duration
- Scrub bar (seek to any position)
- Auto-play next related video (optional)
---
### Reacting to Video
1. Click reaction emoji button (e.g., 👍 Like)
2. Button highlights in color
3. Count increments by 1
4. **Toggle behavior:**
- Click again → removes reaction, count decrements
- Click different emoji → switches reaction
5. Session tracked via cookie (reactions persist across page refreshes)
**Reaction Colors:**
- Like 👍 — Blue
- Love ❤️ — Red
- Laugh 😂 — Yellow
- Surprise 😮 — Purple
- Sad 😢 — Grey
- Angry 😠 — Orange
---
### Commenting
1. Scroll to comments section below video
2. Fill out form:
- **Name** — Required, displayed publicly
- **Email** — Optional, for moderation notifications
- **Comment** — Required, 1-1000 characters
3. Click "Submit Comment"
4. Success message: "Comment submitted for moderation"
5. Comment appears in list with "Pending approval" badge
6. After admin approval, comment visible to all
**Comment Formatting:**
- Plain text only (no HTML)
- URLs auto-linked
- Line breaks preserved
- Profanity filter applied (optional)
---
## Code Examples
### Backend: List Public Videos
```typescript
// api/src/modules/media/routes/public.routes.ts
import { FastifyInstance } from 'fastify';
import { eq, and, isNull, desc } from 'drizzle-orm';
import { videos } from '@/modules/media/db/schema';
import { redisClient } from '@/config/redis';
export default async function (app: FastifyInstance) {
app.get('/api/public/media', async (req, reply) => {
const {
page = 1,
limit = 24,
category,
orientation,
quality,
sort = 'recent',
} = req.query as any;
// Check cache
const cacheKey = `public:videos:${JSON.stringify(req.query)}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return reply.send(JSON.parse(cached));
}
// Build filters
const filters = [
isNull(videos.movedFromPublicAt), // Only public videos
eq(videos.isValid, true),
];
if (category) {
filters.push(eq(videos.category, category));
}
if (orientation) {
filters.push(eq(videos.orientation, orientation));
}
if (quality) {
filters.push(eq(videos.quality, quality));
}
// Build order by
let orderBy;
if (sort === 'popular') {
orderBy = desc(videos.publicViewCount);
} else if (sort === 'trending') {
// Trending = highest view count in last 7 days
// (requires separate view_logs aggregation query)
orderBy = desc(videos.publicViewCount);
} else {
orderBy = desc(videos.createdAt);
}
// Fetch videos
const results = await db
.select({
id: videos.id,
title: videos.title,
producer: videos.producer,
creator: videos.creator,
durationSeconds: videos.durationSeconds,
quality: videos.quality,
orientation: videos.orientation,
thumbnailPath: videos.thumbnailPath,
publicViewCount: videos.publicViewCount,
publicUpvoteCount: videos.publicUpvoteCount,
category: videos.category,
createdAt: videos.createdAt,
})
.from(videos)
.where(and(...filters))
.orderBy(orderBy)
.limit(Number(limit))
.offset((Number(page) - 1) * Number(limit));
// Count total
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(videos)
.where(and(...filters));
const response = {
data: results,
pagination: {
page: Number(page),
limit: Number(limit),
total: Number(count),
totalPages: Math.ceil(Number(count) / Number(limit)),
},
};
// Cache for 5 minutes
await redisClient.setex(cacheKey, 300, JSON.stringify(response));
reply.send(response);
});
}
```
---
### Backend: Track View
```typescript
// api/src/modules/media/routes/public.routes.ts
import { videoViewLogs, videos } from '@/modules/media/db/schema';
import crypto from 'crypto';
app.post('/api/public/media/:id/view', async (req, reply) => {
const { id } = req.params as { id: string };
const { watchTimeSeconds, completed } = req.body as any;
// Get session ID from IP hash
const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
// Check if already viewed in last 24 hours
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const existingView = await db
.select()
.from(videoViewLogs)
.where(
and(
eq(videoViewLogs.videoId, id),
eq(videoViewLogs.sessionId, sessionId),
gte(videoViewLogs.createdAt, yesterday)
)
)
.limit(1);
if (existingView.length > 0) {
// Update watch time if longer than previous
if (watchTimeSeconds > existingView[0].watchTimeSeconds) {
await db
.update(videoViewLogs)
.set({
watchTimeSeconds,
completed: completed || existingView[0].completed,
})
.where(eq(videoViewLogs.id, existingView[0].id));
}
return reply.send({ success: true, newViewCount: null });
}
// Create new view log
await db.insert(videoViewLogs).values({
videoId: id,
sessionId,
watchTimeSeconds,
completed,
});
// Increment view count
const [updated] = await db
.update(videos)
.set({
publicViewCount: sql`${videos.publicViewCount} + 1`,
})
.where(eq(videos.id, id))
.returning({ newViewCount: videos.publicViewCount });
reply.send({ success: true, newViewCount: updated.newViewCount });
});
```
---
### Backend: Add Reaction
```typescript
// api/src/modules/media/routes/public.routes.ts
import { videoReactions } from '@/modules/media/db/schema';
app.post('/api/public/media/:id/reaction', async (req, reply) => {
const { id } = req.params as { id: string };
const { reactionType } = req.body as { reactionType: string };
const validReactions = ['like', 'love', 'laugh', 'surprise', 'sad', 'angry'];
if (!validReactions.includes(reactionType)) {
return reply.code(400).send({ error: 'Invalid reaction type' });
}
const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
// Check existing reaction
const [existing] = await db
.select()
.from(videoReactions)
.where(
and(
eq(videoReactions.videoId, id),
eq(videoReactions.sessionId, sessionId)
)
)
.limit(1);
if (existing) {
if (existing.reactionType === reactionType) {
// Toggle off (remove reaction)
await db
.delete(videoReactions)
.where(eq(videoReactions.id, existing.id));
} else {
// Update to new reaction
await db
.update(videoReactions)
.set({ reactionType })
.where(eq(videoReactions.id, existing.id));
}
} else {
// Insert new reaction
await db.insert(videoReactions).values({
videoId: id,
sessionId,
reactionType,
});
}
// Get updated reaction counts
const reactions = await db
.select({
reactionType: videoReactions.reactionType,
count: sql<number>`count(*)`,
})
.from(videoReactions)
.where(eq(videoReactions.videoId, id))
.groupBy(videoReactions.reactionType);
const reactionCounts = validReactions.reduce((acc, type) => {
acc[type] = reactions.find((r) => r.reactionType === type)?.count || 0;
return acc;
}, {} as Record<string, number>);
reply.send({ success: true, reactions: reactionCounts });
});
```
---
### Frontend: Video Gallery Page
```typescript
// admin/src/pages/public/MediaGalleryPage.tsx
import { Row, Col, Card, Tag, Tabs, Empty } from 'antd';
import { PlayCircleOutlined, EyeOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import InfiniteScroll from 'react-infinite-scroll-component';
export default function MediaGalleryPage() {
const [videos, setVideos] = useState<any[]>([]);
const [category, setCategory] = useState<string>('');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const fetchVideos = async () => {
try {
const { data } = await axios.get('http://api.cmlite.org/api/public/media', {
params: {
page,
limit: 24,
category: category || undefined,
},
});
setVideos((prev) => [...prev, ...data.data]);
setHasMore(page < data.pagination.totalPages);
} catch (error) {
console.error('Failed to fetch videos:', error);
}
};
useEffect(() => {
setVideos([]);
setPage(1);
setHasMore(true);
}, [category]);
useEffect(() => {
fetchVideos();
}, [page, category]);
const categories = [
{ key: '', label: 'All' },
{ key: 'Entertainment', label: 'Entertainment' },
{ key: 'Education', label: 'Education' },
{ key: 'Sports', label: 'Sports' },
{ key: 'News', label: 'News' },
{ key: 'Music', label: 'Music' },
{ key: 'Gaming', label: 'Gaming' },
{ key: 'Science & Tech', label: 'Science & Tech' },
];
return (
<div style={{ padding: 24 }}>
<h1 style={{ fontSize: 32, marginBottom: 24 }}>Video Gallery</h1>
<Tabs
activeKey={category}
onChange={setCategory}
items={categories.map((cat) => ({
key: cat.key,
label: cat.label,
}))}
style={{ marginBottom: 24 }}
/>
<InfiniteScroll
dataLength={videos.length}
next={() => setPage((p) => p + 1)}
hasMore={hasMore}
loader={<div style={{ textAlign: 'center', padding: 24 }}>Loading...</div>}
endMessage={
<Empty description="No more videos" style={{ marginTop: 48 }} />
}
>
<Row gutter={[16, 16]}>
{videos.map((video) => (
<Col key={video.id} xs={24} sm={12} md={8} lg={6}>
<Card
hoverable
cover={
<div
style={{
position: 'relative',
paddingTop: '56.25%',
background: '#000',
}}
>
<img
src={video.thumbnailPath || '/placeholder.jpg'}
alt={video.title}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '4px 8px',
borderRadius: 4,
fontSize: 12,
}}
>
{Math.floor(video.durationSeconds / 60)}:
{(video.durationSeconds % 60).toString().padStart(2, '0')}
</div>
<PlayCircleOutlined
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: 48,
color: '#fff',
opacity: 0.8,
}}
/>
</div>
}
onClick={() => (window.location.href = `/media/${video.id}`)}
>
<Card.Meta
title={
<div style={{ fontSize: 14, height: 40, overflow: 'hidden' }}>
{video.title}
</div>
}
description={
<div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
{video.producer}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 12 }}>
<EyeOutlined /> {video.publicViewCount.toLocaleString()}
</span>
<Tag color={video.quality === 'UHD' ? 'purple' : 'blue'}>
{video.quality}
</Tag>
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
</InfiniteScroll>
</div>
);
}
```
---
### Frontend: Video Player Page
```typescript
// admin/src/pages/public/MediaViewerPage.tsx
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import axios from 'axios';
import ReactPlayer from 'react-player';
import { Button, Row, Col, Card, Divider, Form, Input, message } from 'antd';
export default function MediaViewerPage() {
const { id } = useParams<{ id: string }>();
const [video, setVideo] = useState<any>(null);
const [watchTime, setWatchTime] = useState(0);
const [userReaction, setUserReaction] = useState<string | null>(null);
useEffect(() => {
fetchVideo();
}, [id]);
const fetchVideo = async () => {
const { data } = await axios.get(`http://api.cmlite.org/api/public/media/${id}`);
setVideo(data.video);
};
const trackView = async () => {
await axios.post(`http://api.cmlite.org/api/public/media/${id}/view`, {
watchTimeSeconds: watchTime,
completed: watchTime >= video.durationSeconds * 0.9,
});
};
const handleReaction = async (reactionType: string) => {
const { data } = await axios.post(`http://api.cmlite.org/api/public/media/${id}/reaction`, {
reactionType,
});
setUserReaction(userReaction === reactionType ? null : reactionType);
setVideo({ ...video, reactions: data.reactions });
};
const handleSubmitComment = async (values: any) => {
await axios.post(`http://api.cmlite.org/api/public/media/${id}/comment`, values);
message.success('Comment submitted for moderation');
};
if (!video) return <div>Loading...</div>;
const reactions = [
{ type: 'like', emoji: '👍', label: 'Like' },
{ type: 'love', emoji: '❤️', label: 'Love' },
{ type: 'laugh', emoji: '😂', label: 'Laugh' },
{ type: 'surprise', emoji: '😮', label: 'Surprise' },
{ type: 'sad', emoji: '😢', label: 'Sad' },
{ type: 'angry', emoji: '😠', label: 'Angry' },
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
<Row gutter={24}>
<Col span={16}>
<ReactPlayer
url={`/media/videos/${video.id}.mp4`}
controls
width="100%"
height="auto"
onProgress={(state) => setWatchTime(Math.floor(state.playedSeconds))}
onPause={trackView}
onEnded={trackView}
/>
<h1 style={{ marginTop: 16 }}>{video.title}</h1>
<div style={{ color: '#888', marginBottom: 16 }}>
{video.producer} {video.publicViewCount.toLocaleString()} views
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
{reactions.map((r) => (
<Button
key={r.type}
type={userReaction === r.type ? 'primary' : 'default'}
onClick={() => handleReaction(r.type)}
>
<span style={{ fontSize: 20, marginRight: 4 }}>{r.emoji}</span>
{video.reactions[r.type] || 0}
</Button>
))}
</div>
<Divider />
<h3>Comments</h3>
{video.comments.map((comment: any) => (
<Card key={comment.id} style={{ marginBottom: 16 }}>
<Card.Meta
title={comment.name}
description={comment.comment}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
{new Date(comment.createdAt).toLocaleDateString()}
</div>
</Card>
))}
<Form onFinish={handleSubmitComment} layout="vertical">
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Email" name="email" rules={[{ type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item label="Comment" name="comment" rules={[{ required: true }]}>
<Input.TextArea rows={4} />
</Form.Item>
<Button type="primary" htmlType="submit">
Submit Comment
</Button>
</Form>
</Col>
<Col span={8}>
<h3>Related Videos</h3>
{video.relatedVideos.map((related: any) => (
<Card
key={related.id}
hoverable
cover={<img src={related.thumbnailPath} alt={related.title} />}
onClick={() => (window.location.href = `/media/${related.id}`)}
style={{ marginBottom: 16 }}
>
<Card.Meta title={related.title} />
</Card>
))}
</Col>
</Row>
</div>
);
}
```
---
## Troubleshooting
### Problem: Videos Not Appearing in Gallery
**Symptoms:**
- SharedMediaPage shows videos marked as public
- Public gallery shows "No videos found"
- API returns empty array
**Solutions:**
1. **Check `movedFromPublicAt` field:**
```sql
SELECT id, title, moved_from_public_at FROM videos WHERE moved_from_public_at IS NULL;
-- Should show public videos
-- If all have timestamps, videos were unlocked
-- Fix: Set to NULL for videos that should be public
UPDATE videos SET moved_from_public_at = NULL WHERE id = 'VIDEO_ID';
```
2. **Verify `isValid = true`:**
```sql
SELECT id, title, is_valid FROM videos WHERE is_valid = false;
-- Invalid videos hidden from public
-- Fix: Validate videos to mark as valid
```
3. **Check Redis cache:**
```bash
# Clear public video cache
docker compose exec redis redis-cli
> KEYS public:videos:*
> DEL public:videos:*
# Refresh gallery page
```
4. **Test API directly:**
```bash
curl http://localhost:4100/api/public/media
# Should return JSON with videos array
```
---
### Problem: Reactions Not Saving
**Symptoms:**
- Click reaction button, count doesn't increment
- Refresh page, reaction disappears
- No errors in console
**Solutions:**
1. **Check session ID generation:**
```typescript
// Backend should use consistent session ID
const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
// Or use cookie for persistence
const sessionId = req.cookies.sessionId || randomUUID();
res.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 });
```
2. **Verify database insert:**
```sql
SELECT * FROM video_reactions WHERE video_id = 'VIDEO_ID';
-- Should show reaction records
-- If empty, insert is failing
-- Check unique constraint: (video_id, session_id)
```
3. **Test reaction endpoint:**
```bash
curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \
-H "Content-Type: application/json" \
-d '{"reactionType": "like"}'
# Should return updated reaction counts
```
---
### Problem: Comments Not Showing After Approval
**Symptoms:**
- Admin approves comment
- Comment still doesn't appear on video page
- Database shows `approved = true`
**Solutions:**
1. **Check query filter:**
```typescript
// Backend should filter for approved comments
const comments = await db
.select()
.from(videoComments)
.where(
and(
eq(videoComments.videoId, videoId),
eq(videoComments.approved, true) // MUST include this
)
)
.orderBy(desc(videoComments.createdAt));
```
2. **Clear cache:**
```bash
# Video details may be cached
docker compose exec redis redis-cli DEL "public:video:VIDEO_ID"
```
3. **Verify approval:**
```sql
SELECT id, comment, approved FROM video_comments WHERE video_id = 'VIDEO_ID';
-- Should show approved = true
```
---
## Performance Considerations
### Redis Caching Strategy
**Cache Keys:**
- `public:videos:{query}` — List of videos (5 min TTL)
- `public:video:{id}` — Video details (10 min TTL)
- `public:stats` — Gallery-wide stats (15 min TTL)
**Cache Invalidation:**
```typescript
// When admin shares/unshares video
await redisClient.del(`public:videos:*`); // Clear all list caches
await redisClient.del(`public:video:${videoId}`); // Clear detail cache
// When comment approved
await redisClient.del(`public:video:${videoId}`); // Refresh comments
```
---
### Database Indexes
```sql
-- Public video queries
CREATE INDEX idx_videos_public ON videos(moved_from_public_at) WHERE moved_from_public_at IS NULL;
CREATE INDEX idx_videos_category ON videos(category, created_at DESC);
CREATE INDEX idx_videos_popular ON videos(public_view_count DESC);
-- Reactions
CREATE INDEX idx_reactions_video ON video_reactions(video_id);
CREATE INDEX idx_reactions_session ON video_reactions(session_id);
-- Comments
CREATE INDEX idx_comments_video_approved ON video_comments(video_id, approved);
-- View logs
CREATE INDEX idx_view_logs_video ON video_view_logs(video_id);
CREATE INDEX idx_view_logs_recent ON video_view_logs(created_at DESC);
```
---
### SEO Optimization
**Server-Side Rendering (Future):**
```typescript
// Next.js or similar for SSR
export async function getServerSideProps({ params }: { params: { id: string } }) {
const video = await fetchVideo(params.id);
return {
props: {
video,
meta: {
title: video.title,
description: `Watch ${video.title} by ${video.producer}`,
image: video.thumbnailPath,
url: `https://cmlite.org/media/${video.id}`,
},
},
};
}
```
**Meta Tags:**
```html
<head>
<title>Amazing Sports Highlight | CMLite Gallery</title>
<meta name="description" content="Watch Amazing Sports Highlight by Studio A. 1,250 views.">
<meta property="og:title" content="Amazing Sports Highlight">
<meta property="og:description" content="Watch Amazing Sports Highlight by Studio A">
<meta property="og:image" content="https://cmlite.org/media/thumbnails/550e8400.jpg">
<meta property="og:url" content="https://cmlite.org/media/550e8400">
<meta property="og:type" content="video.other">
<meta name="twitter:card" content="player">
<meta name="twitter:title" content="Amazing Sports Highlight">
<meta name="twitter:image" content="https://cmlite.org/media/thumbnails/550e8400.jpg">
</head>
```
**Sitemap Generation:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://cmlite.org/media</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://cmlite.org/media/550e8400-e29b-41d4-a716-446655440000</loc>
<lastmod>2026-02-10</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<!-- ... more video URLs -->
</urlset>
```
---
## Security Considerations
### Rate Limiting
```typescript
// Public endpoints more restrictive than admin
import rateLimit from '@fastify/rate-limit';
app.register(rateLimit, {
max: 100, // 100 requests
timeWindow: '1 minute',
allowList: [], // No whitelist for public
});
```
**Per-Endpoint Limits:**
- List videos: 100/min
- Video details: 100/min
- Track view: 10/min (prevent view count manipulation)
- Add reaction: 20/min
- Submit comment: 5/hour (anti-spam)
---
### Content Moderation
**Comment Filtering:**
```typescript
import Filter from 'bad-words';
const filter = new Filter();
const sanitizeComment = (comment: string): string => {
// Remove HTML tags
const cleaned = comment.replace(/<[^>]*>/g, '');
// Filter profanity
return filter.clean(cleaned);
};
```
**Spam Detection:**
```typescript
// Reject duplicate comments
const existingComment = await db.select()
.from(videoComments)
.where(
and(
eq(videoComments.sessionId, sessionId),
eq(videoComments.comment, comment),
gte(videoComments.createdAt, new Date(Date.now() - 24 * 60 * 60 * 1000))
)
)
.limit(1);
if (existingComment.length > 0) {
return reply.code(429).send({ error: 'Duplicate comment detected' });
}
```
---
### Privacy Protection
**Never Expose:**
- Internal file paths (`/media/local/library/...`)
- Original filenames (use video ID for playback URL)
- Admin user information
- Email addresses from comments (unless user explicitly made public)
**Session Tracking:**
```typescript
// Use IP hash (not raw IP) for session ID
const sessionId = crypto.createHash('sha256').update(req.ip + 'SECRET_SALT').digest('hex');
// Store minimal data in session
// NO: { userId: 123, name: 'John', email: 'john@example.com' }
// YES: { sessionId: 'abc123' }
```
---
## Related Documentation
### Backend Documentation
- **Public Routes:** `backend/modules/media/public.md` — Public API endpoints
- **Reactions Service:** `backend/modules/media/reactions.md` — Reaction system implementation
- **Comments Service:** `backend/modules/media/comments.md` — Comment moderation system
### Frontend Documentation
- **Media Gallery Page:** `frontend/pages/public/media-gallery.md` — Gallery UI implementation
- **Video Player Page:** `frontend/pages/public/media-viewer.md` — Player component
### Feature Documentation
- **Video Library:** `features/media/video-library.md` — Admin video management
- **Shared Media:** `features/media/shared-media.md` — Sharing controls (admin)
---
## Next Steps
After mastering the public gallery:
1. **Analytics Dashboard** — Build admin dashboard showing view trends, popular videos, engagement metrics
2. **Playlist System** — Allow users to create and share playlists
3. **Video Embedding** — Generate embed codes for external websites
4. **Advanced Search** — Full-text search across titles, producers, creators, tags
**Hands-On Practice:**
```bash
# 1. Share video via API
curl -X PUT http://localhost:4100/api/media/videos/VIDEO_ID/share \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"category": "Sports"}'
# 2. Browse public gallery
curl http://localhost:4100/api/public/media?category=Sports
# 3. Track view
curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/view \
-H "Content-Type: application/json" \
-d '{"watchTimeSeconds": 120, "completed": true}'
# 4. Add reaction
curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \
-H "Content-Type: application/json" \
-d '{"reactionType": "like"}'
```
---
**Last Updated:** 2026-02-13
**Version:** V2.0
**Maintainer:** Changemaker Lite Team