42 KiB

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

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)

// 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

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:

// 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

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

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:

// 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

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:

{
  "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:

// 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

GET /api/public/media/:id

Response:

{
  "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:

// 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

POST /api/public/media/:id/view

Request Body:

{
  "watchTimeSeconds": 120,
  "completed": true
}

Response:

{
  "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

POST /api/public/media/:id/reaction

Request Body:

{
  "reactionType": "like"
}

Response:

{
  "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

POST /api/public/media/:id/comment

Request Body:

{
  "name": "John Doe",
  "email": "john@example.com",
  "comment": "This video is amazing! Thanks for sharing."
}

Response:

{
  "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:

// 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

  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

// 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

// 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

// 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 });
});

// 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

// 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

Symptoms:

  • SharedMediaPage shows videos marked as public
  • Public gallery shows "No videos found"
  • API returns empty array

Solutions:

  1. Check movedFromPublicAt field:
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';
  1. Verify isValid = true:
SELECT id, title, is_valid FROM videos WHERE is_valid = false;
-- Invalid videos hidden from public

-- Fix: Validate videos to mark as valid
  1. Check Redis cache:
# Clear public video cache
docker compose exec redis redis-cli
> KEYS public:videos:*
> DEL public:videos:*

# Refresh gallery page
  1. Test API directly:
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:
// 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 });
  1. Verify database insert:
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)
  1. Test reaction endpoint:
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:
// 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));
  1. Clear cache:
# Video details may be cached
docker compose exec redis redis-cli DEL "public:video:VIDEO_ID"
  1. Verify approval:
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:

// 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

-- 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):

// 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:

<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 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

// 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:

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:

// 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:

// 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' }

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:

# 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