From 048b1b9d9dad141a6d7232e27d3f91a3d36d9190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Star?= <66201416+huzely@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:39:01 +0700 Subject: [PATCH 1/2] Build fullstack video website --- .gitignore | 7 + README.md | 72 +++++++--- backend/.env.example | 3 + backend/config/db.js | 11 ++ backend/controllers/adController.js | 25 ++++ backend/controllers/announcementController.js | 23 +++ backend/controllers/commentController.js | 11 ++ backend/controllers/metaController.js | 46 ++++++ backend/controllers/settingsController.js | 18 +++ backend/controllers/statsController.js | 49 +++++++ backend/controllers/videoController.js | 118 +++++++++++++++ backend/models/Ad.js | 13 ++ backend/models/Announcement.js | 12 ++ backend/models/Category.js | 11 ++ backend/models/Comment.js | 11 ++ backend/models/Setting.js | 13 ++ backend/models/Tag.js | 10 ++ backend/models/Video.js | 17 +++ backend/models/ViewLog.js | 11 ++ backend/package.json | 20 +++ backend/routes/adRoutes.js | 9 ++ backend/routes/announcementRoutes.js | 9 ++ backend/routes/commentRoutes.js | 6 + backend/routes/metaRoutes.js | 25 ++++ backend/routes/searchRoutes.js | 8 ++ backend/routes/settingsRoutes.js | 7 + backend/routes/statsRoutes.js | 8 ++ backend/routes/videoRoutes.js | 12 ++ backend/seed/seedData.js | 56 ++++++++ backend/server.js | 46 ++++++ frontend/index.html | 12 ++ frontend/package.json | 22 +++ frontend/src/App.jsx | 111 ++++++++++++++ frontend/src/api/client.js | 5 + frontend/src/components/AdBanner.jsx | 9 ++ frontend/src/components/Layout.jsx | 26 ++++ frontend/src/components/PopupAd.jsx | 27 ++++ frontend/src/components/Skeletons.jsx | 17 +++ frontend/src/components/VideoCard.jsx | 16 +++ frontend/src/main.jsx | 13 ++ frontend/src/pages/AdminPage.jsx | 135 ++++++++++++++++++ frontend/src/pages/HomePage.jsx | 47 ++++++ frontend/src/pages/UploadPage.jsx | 54 +++++++ frontend/src/pages/VideoDetailPage.jsx | 72 ++++++++++ frontend/src/styles/global.css | 63 ++++++++ frontend/vite.config.js | 9 ++ 46 files changed, 1304 insertions(+), 21 deletions(-) create mode 100644 .gitignore create mode 100644 backend/.env.example create mode 100644 backend/config/db.js create mode 100644 backend/controllers/adController.js create mode 100644 backend/controllers/announcementController.js create mode 100644 backend/controllers/commentController.js create mode 100644 backend/controllers/metaController.js create mode 100644 backend/controllers/settingsController.js create mode 100644 backend/controllers/statsController.js create mode 100644 backend/controllers/videoController.js create mode 100644 backend/models/Ad.js create mode 100644 backend/models/Announcement.js create mode 100644 backend/models/Category.js create mode 100644 backend/models/Comment.js create mode 100644 backend/models/Setting.js create mode 100644 backend/models/Tag.js create mode 100644 backend/models/Video.js create mode 100644 backend/models/ViewLog.js create mode 100644 backend/package.json create mode 100644 backend/routes/adRoutes.js create mode 100644 backend/routes/announcementRoutes.js create mode 100644 backend/routes/commentRoutes.js create mode 100644 backend/routes/metaRoutes.js create mode 100644 backend/routes/searchRoutes.js create mode 100644 backend/routes/settingsRoutes.js create mode 100644 backend/routes/statsRoutes.js create mode 100644 backend/routes/videoRoutes.js create mode 100644 backend/seed/seedData.js create mode 100644 backend/server.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/components/AdBanner.jsx create mode 100644 frontend/src/components/Layout.jsx create mode 100644 frontend/src/components/PopupAd.jsx create mode 100644 frontend/src/components/Skeletons.jsx create mode 100644 frontend/src/components/VideoCard.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AdminPage.jsx create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/UploadPage.jsx create mode 100644 frontend/src/pages/VideoDetailPage.jsx create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..852696f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +backend/node_modules +frontend/node_modules +dist +coverage +.DS_Store diff --git a/README.md b/README.md index cdc16c5..8f916f1 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,58 @@ -# Decrypt -### [+] Created By HTR-TECH (@***tahmid.rayat***) -### [+] Disclaimer : -***Decrypter is a tool to decrypt Encrypted Bash Scripts into a Readable Format.This Tool is created for Educational Purpose only.I am not responsible for any misuse of this tool.*** - - - -### [+] Installation -```apt update``` +# NightFlix Fullstack Video Website + +A complete fullstack video website built with React (Vite), Node.js/Express, and MongoDB. The project includes a public video browsing experience plus an admin dashboard for content, ads, announcements, analytics, and site settings. + +## Project structure + +- `backend/` – Express API, MongoDB models, seeding script, analytics endpoints +- `frontend/` – React + Vite client with home page, detail page, upload page, and admin dashboard + +## Features + +### Public site +- Home page with responsive dark-mode video grid +- Search by title or tags +- Category and tag filters +- Video detail page with HTML5 player or iframe embeds +- Related videos +- Comment system +- Popup ad, header/middle/footer banner ads +- Announcement banner +- Upload page + +### Admin panel +- Overview cards for total videos and total views +- Daily and monthly analytics charts +- CRUD for videos, categories, tags, ads, and announcements +- Site settings editor for branding and popup ads toggle + +## Run locally + +### 1) Backend +```bash +cd backend +cp .env.example .env +npm install +npm run seed +npm run dev +``` -```apt install git python2 -y``` +Backend runs on `http://localhost:3000`. -```git clone https://github.com/hax0rtahm1d/decrypt``` +### 2) Frontend +```bash +cd frontend +npm install +npm run dev +``` -```cd decrypt``` +Frontend runs on `http://localhost:5173`. -```python2 dec.py``` +## MongoDB +Make sure MongoDB is running locally at the URI defined in `backend/.env`. -### Or, Use Single Command +Default local URI: +```bash +mongodb://127.0.0.1:27017/video_site ``` -apt update && apt install git python2 -y && git clone https://github.com/hax0rtahm1d/decrypt && cd decrypt && python2 dec.py -``` - -## [+] Find Me on : -[![Github](https://img.shields.io/badge/Github-HTR--TECH-green?style=for-the-badge&logo=github)](https://github.com/htr-tech) -[![Instagram](https://img.shields.io/badge/IG-%40tahmid.rayat-red?style=for-the-badge&logo=instagram)](https://www.instagram.com/tahmid.rayat) -[![Messenger](https://img.shields.io/badge/Chat-Messenger-blue?style=for-the-badge&logo=messenger)](https://m.me/tahmid.rayat.official) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..54a20f3 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +MONGODB_URI=mongodb://127.0.0.1:27017/video_site +FRONTEND_URL=http://localhost:5173 diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 0000000..3b9a7b9 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +export async function connectDB(uri) { + try { + await mongoose.connect(uri); + console.log('MongoDB connected'); + } catch (error) { + console.error('MongoDB connection error:', error.message); + process.exit(1); + } +} diff --git a/backend/controllers/adController.js b/backend/controllers/adController.js new file mode 100644 index 0000000..c4c4e59 --- /dev/null +++ b/backend/controllers/adController.js @@ -0,0 +1,25 @@ +import { Ad } from '../models/Ad.js'; + +export async function getAds(req, res) { + const filter = {}; + if (req.query.active === 'true') filter.active = true; + const ads = await Ad.find(filter).sort({ createdAt: -1 }); + res.json(ads); +} + +export async function createAd(req, res) { + const ad = await Ad.create(req.body); + res.status(201).json(ad); +} + +export async function updateAd(req, res) { + const ad = await Ad.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); + if (!ad) return res.status(404).json({ message: 'Ad not found' }); + res.json(ad); +} + +export async function deleteAd(req, res) { + const ad = await Ad.findByIdAndDelete(req.params.id); + if (!ad) return res.status(404).json({ message: 'Ad not found' }); + res.json({ message: 'Ad deleted' }); +} diff --git a/backend/controllers/announcementController.js b/backend/controllers/announcementController.js new file mode 100644 index 0000000..240e36f --- /dev/null +++ b/backend/controllers/announcementController.js @@ -0,0 +1,23 @@ +import { Announcement } from '../models/Announcement.js'; + +export async function getAnnouncement(req, res) { + const announcement = await Announcement.findOne({ active: true }).sort({ updatedAt: -1 }); + res.json(announcement); +} + +export async function createAnnouncement(req, res) { + const announcement = await Announcement.create(req.body); + res.status(201).json(announcement); +} + +export async function updateAnnouncement(req, res) { + const announcement = await Announcement.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); + if (!announcement) return res.status(404).json({ message: 'Announcement not found' }); + res.json(announcement); +} + +export async function deleteAnnouncement(req, res) { + const announcement = await Announcement.findByIdAndDelete(req.params.id); + if (!announcement) return res.status(404).json({ message: 'Announcement not found' }); + res.json({ message: 'Announcement deleted' }); +} diff --git a/backend/controllers/commentController.js b/backend/controllers/commentController.js new file mode 100644 index 0000000..85d951d --- /dev/null +++ b/backend/controllers/commentController.js @@ -0,0 +1,11 @@ +import { Comment } from '../models/Comment.js'; + +export async function createComment(req, res) { + const { videoId, text } = req.body; + if (!videoId || !text?.trim()) { + return res.status(400).json({ message: 'videoId and text are required' }); + } + + const comment = await Comment.create({ videoId, text: text.trim() }); + res.status(201).json(comment); +} diff --git a/backend/controllers/metaController.js b/backend/controllers/metaController.js new file mode 100644 index 0000000..50a24b1 --- /dev/null +++ b/backend/controllers/metaController.js @@ -0,0 +1,46 @@ +import { Category } from '../models/Category.js'; +import { Tag } from '../models/Tag.js'; + +export async function listCategories(req, res) { + const categories = await Category.find().sort({ name: 1 }); + res.json(categories); +} + +export async function createCategory(req, res) { + const category = await Category.create(req.body); + res.status(201).json(category); +} + +export async function updateCategory(req, res) { + const category = await Category.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); + if (!category) return res.status(404).json({ message: 'Category not found' }); + res.json(category); +} + +export async function deleteCategory(req, res) { + const category = await Category.findByIdAndDelete(req.params.id); + if (!category) return res.status(404).json({ message: 'Category not found' }); + res.json({ message: 'Category deleted' }); +} + +export async function listTags(req, res) { + const tags = await Tag.find().sort({ name: 1 }); + res.json(tags); +} + +export async function createTag(req, res) { + const tag = await Tag.create(req.body); + res.status(201).json(tag); +} + +export async function updateTag(req, res) { + const tag = await Tag.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); + if (!tag) return res.status(404).json({ message: 'Tag not found' }); + res.json(tag); +} + +export async function deleteTag(req, res) { + const tag = await Tag.findByIdAndDelete(req.params.id); + if (!tag) return res.status(404).json({ message: 'Tag not found' }); + res.json({ message: 'Tag deleted' }); +} diff --git a/backend/controllers/settingsController.js b/backend/controllers/settingsController.js new file mode 100644 index 0000000..9ee7c31 --- /dev/null +++ b/backend/controllers/settingsController.js @@ -0,0 +1,18 @@ +import { Setting } from '../models/Setting.js'; + +export async function getSettings(req, res) { + const settings = await Setting.findOne().sort({ updatedAt: -1 }); + res.json(settings); +} + +export async function saveSettings(req, res) { + const existing = await Setting.findOne(); + if (existing) { + Object.assign(existing, req.body); + await existing.save(); + return res.json(existing); + } + + const settings = await Setting.create(req.body); + res.status(201).json(settings); +} diff --git a/backend/controllers/statsController.js b/backend/controllers/statsController.js new file mode 100644 index 0000000..080892d --- /dev/null +++ b/backend/controllers/statsController.js @@ -0,0 +1,49 @@ +import { Video } from '../models/Video.js'; +import { ViewLog } from '../models/ViewLog.js'; + +export async function getOverview(req, res) { + const [totalVideos, totalViewsAgg] = await Promise.all([ + Video.countDocuments(), + Video.aggregate([{ $group: { _id: null, totalViews: { $sum: '$views' } } }]) + ]); + + res.json({ + totalVideos, + totalViews: totalViewsAgg[0]?.totalViews || 0 + }); +} + +export async function getDaily(req, res) { + const data = await ViewLog.aggregate([ + { + $group: { + _id: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + count: { $sum: 1 } + } + }, + { $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } } + ]); + + res.json(data.map((item) => ({ date: `${item._id.year}-${String(item._id.month).padStart(2, '0')}-${String(item._id.day).padStart(2, '0')}`, count: item.count }))); +} + +export async function getMonthly(req, res) { + const data = await ViewLog.aggregate([ + { + $group: { + _id: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' } + }, + count: { $sum: 1 } + } + }, + { $sort: { '_id.year': 1, '_id.month': 1 } } + ]); + + res.json(data.map((item) => ({ month: `${item._id.year}-${String(item._id.month).padStart(2, '0')}`, count: item.count }))); +} diff --git a/backend/controllers/videoController.js b/backend/controllers/videoController.js new file mode 100644 index 0000000..87560e0 --- /dev/null +++ b/backend/controllers/videoController.js @@ -0,0 +1,118 @@ +import mongoose from 'mongoose'; +import { Video } from '../models/Video.js'; +import { Comment } from '../models/Comment.js'; +import { ViewLog } from '../models/ViewLog.js'; +import { Tag } from '../models/Tag.js'; +import { Category } from '../models/Category.js'; + +function normalizeTags(tags) { + if (Array.isArray(tags)) return tags.map((tag) => String(tag).trim()).filter(Boolean); + if (typeof tags === 'string') { + return tags.split(',').map((tag) => tag.trim()).filter(Boolean); + } + return []; +} + +async function syncMetadata(category, tags) { + if (category) { + await Category.findOneAndUpdate( + { name: category.trim() }, + { $setOnInsert: { name: category.trim() } }, + { upsert: true, new: true } + ); + } + + if (tags.length) { + await Promise.all( + tags.map((name) => + Tag.findOneAndUpdate({ name }, { $setOnInsert: { name } }, { upsert: true, new: true }) + ) + ); + } +} + +export async function getVideos(req, res) { + const { category, tag, q } = req.query; + const filter = {}; + + if (category) filter.category = category; + if (tag) filter.tags = tag; + if (q) { + filter.$or = [ + { title: { $regex: q, $options: 'i' } }, + { tags: { $elemMatch: { $regex: q, $options: 'i' } } } + ]; + } + + const videos = await Video.find(filter).sort({ createdAt: -1 }); + res.json(videos); +} + +export async function getVideoById(req, res) { + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: 'Invalid video id' }); + } + + const video = await Video.findById(id); + if (!video) { + return res.status(404).json({ message: 'Video not found' }); + } + + video.views += 1; + await video.save(); + + await ViewLog.create({ + videoId: video._id, + ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown' + }); + + const comments = await Comment.find({ videoId: video._id }).sort({ createdAt: -1 }); + const relatedVideos = await Video.find({ + _id: { $ne: video._id }, + $or: [{ category: video.category }, { tags: { $in: video.tags } }] + }) + .limit(8) + .sort({ views: -1, createdAt: -1 }); + + return res.json({ ...video.toObject(), comments, relatedVideos }); +} + +export async function createVideo(req, res) { + const payload = req.body; + const tags = normalizeTags(payload.tags); + const video = await Video.create({ ...payload, tags }); + await syncMetadata(video.category, tags); + res.status(201).json(video); +} + +export async function updateVideo(req, res) { + const { id } = req.params; + const payload = req.body; + const tags = normalizeTags(payload.tags); + const video = await Video.findByIdAndUpdate(id, { ...payload, tags }, { new: true, runValidators: true }); + if (!video) return res.status(404).json({ message: 'Video not found' }); + await syncMetadata(video.category, tags); + res.json(video); +} + +export async function deleteVideo(req, res) { + const { id } = req.params; + const video = await Video.findByIdAndDelete(id); + if (!video) return res.status(404).json({ message: 'Video not found' }); + await Comment.deleteMany({ videoId: id }); + await ViewLog.deleteMany({ videoId: id }); + res.json({ message: 'Video deleted' }); +} + +export async function searchVideos(req, res) { + const q = req.query.q || ''; + const videos = await Video.find({ + $or: [ + { title: { $regex: q, $options: 'i' } }, + { tags: { $elemMatch: { $regex: q, $options: 'i' } } } + ] + }).sort({ createdAt: -1 }); + + res.json(videos); +} diff --git a/backend/models/Ad.js b/backend/models/Ad.js new file mode 100644 index 0000000..cad855b --- /dev/null +++ b/backend/models/Ad.js @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +const adSchema = new mongoose.Schema( + { + image: { type: String, required: true }, + link: { type: String, required: true }, + position: { type: String, enum: ['popup', 'header', 'middle', 'footer'], required: true }, + active: { type: Boolean, default: true } + }, + { timestamps: true } +); + +export const Ad = mongoose.model('Ad', adSchema); diff --git a/backend/models/Announcement.js b/backend/models/Announcement.js new file mode 100644 index 0000000..ccf9792 --- /dev/null +++ b/backend/models/Announcement.js @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; + +const announcementSchema = new mongoose.Schema( + { + title: { type: String, required: true }, + content: { type: String, required: true }, + active: { type: Boolean, default: true } + }, + { timestamps: true } +); + +export const Announcement = mongoose.model('Announcement', announcementSchema); diff --git a/backend/models/Category.js b/backend/models/Category.js new file mode 100644 index 0000000..2b4bc60 --- /dev/null +++ b/backend/models/Category.js @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const categorySchema = new mongoose.Schema( + { + name: { type: String, required: true, unique: true, trim: true }, + description: { type: String, default: '' } + }, + { timestamps: true } +); + +export const Category = mongoose.model('Category', categorySchema); diff --git a/backend/models/Comment.js b/backend/models/Comment.js new file mode 100644 index 0000000..44be812 --- /dev/null +++ b/backend/models/Comment.js @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const commentSchema = new mongoose.Schema( + { + videoId: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, + text: { type: String, required: true, trim: true } + }, + { timestamps: true } +); + +export const Comment = mongoose.model('Comment', commentSchema); diff --git a/backend/models/Setting.js b/backend/models/Setting.js new file mode 100644 index 0000000..9f6dc30 --- /dev/null +++ b/backend/models/Setting.js @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +const settingSchema = new mongoose.Schema( + { + siteName: { type: String, default: 'NightFlix' }, + logo: { type: String, default: '' }, + primaryColor: { type: String, default: '#e50914' }, + popupAdsEnabled: { type: Boolean, default: true } + }, + { timestamps: true } +); + +export const Setting = mongoose.model('Setting', settingSchema); diff --git a/backend/models/Tag.js b/backend/models/Tag.js new file mode 100644 index 0000000..2eb4824 --- /dev/null +++ b/backend/models/Tag.js @@ -0,0 +1,10 @@ +import mongoose from 'mongoose'; + +const tagSchema = new mongoose.Schema( + { + name: { type: String, required: true, unique: true, trim: true } + }, + { timestamps: true } +); + +export const Tag = mongoose.model('Tag', tagSchema); diff --git a/backend/models/Video.js b/backend/models/Video.js new file mode 100644 index 0000000..da2074d --- /dev/null +++ b/backend/models/Video.js @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +const videoSchema = new mongoose.Schema( + { + title: { type: String, required: true, trim: true }, + description: { type: String, default: '' }, + thumbnail: { type: String, required: true }, + videoUrl: { type: String, required: true }, + type: { type: String, enum: ['mp4', 'embed'], required: true }, + views: { type: Number, default: 0 }, + tags: [{ type: String, trim: true }], + category: { type: String, required: true, trim: true } + }, + { timestamps: true } +); + +export const Video = mongoose.model('Video', videoSchema); diff --git a/backend/models/ViewLog.js b/backend/models/ViewLog.js new file mode 100644 index 0000000..2bb05e7 --- /dev/null +++ b/backend/models/ViewLog.js @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const viewLogSchema = new mongoose.Schema( + { + videoId: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, + ip: { type: String, default: 'unknown' } + }, + { timestamps: true } +); + +export const ViewLog = mongoose.model('ViewLog', viewLogSchema); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b437b40 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,20 @@ +{ + "name": "video-site-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "nodemon server.js", + "start": "node server.js", + "seed": "node seed/seedData.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "mongoose": "^8.6.1", + "morgan": "^1.10.0" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } +} diff --git a/backend/routes/adRoutes.js b/backend/routes/adRoutes.js new file mode 100644 index 0000000..a6c085e --- /dev/null +++ b/backend/routes/adRoutes.js @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { createAd, deleteAd, getAds, updateAd } from '../controllers/adController.js'; + +const router = Router(); +router.get('/', getAds); +router.post('/', createAd); +router.put('/:id', updateAd); +router.delete('/:id', deleteAd); +export default router; diff --git a/backend/routes/announcementRoutes.js b/backend/routes/announcementRoutes.js new file mode 100644 index 0000000..aee8ca5 --- /dev/null +++ b/backend/routes/announcementRoutes.js @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { createAnnouncement, deleteAnnouncement, getAnnouncement, updateAnnouncement } from '../controllers/announcementController.js'; + +const router = Router(); +router.get('/', getAnnouncement); +router.post('/', createAnnouncement); +router.put('/:id', updateAnnouncement); +router.delete('/:id', deleteAnnouncement); +export default router; diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js new file mode 100644 index 0000000..4027a3f --- /dev/null +++ b/backend/routes/commentRoutes.js @@ -0,0 +1,6 @@ +import { Router } from 'express'; +import { createComment } from '../controllers/commentController.js'; + +const router = Router(); +router.post('/', createComment); +export default router; diff --git a/backend/routes/metaRoutes.js b/backend/routes/metaRoutes.js new file mode 100644 index 0000000..ba57116 --- /dev/null +++ b/backend/routes/metaRoutes.js @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { + createCategory, + createTag, + deleteCategory, + deleteTag, + listCategories, + listTags, + updateCategory, + updateTag +} from '../controllers/metaController.js'; + +const categoryRouter = Router(); +categoryRouter.get('/', listCategories); +categoryRouter.post('/', createCategory); +categoryRouter.put('/:id', updateCategory); +categoryRouter.delete('/:id', deleteCategory); + +const tagRouter = Router(); +tagRouter.get('/', listTags); +tagRouter.post('/', createTag); +tagRouter.put('/:id', updateTag); +tagRouter.delete('/:id', deleteTag); + +export { categoryRouter, tagRouter }; diff --git a/backend/routes/searchRoutes.js b/backend/routes/searchRoutes.js new file mode 100644 index 0000000..8983fdc --- /dev/null +++ b/backend/routes/searchRoutes.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { searchVideos } from '../controllers/videoController.js'; + +const router = Router(); + +router.get('/', searchVideos); + +export default router; diff --git a/backend/routes/settingsRoutes.js b/backend/routes/settingsRoutes.js new file mode 100644 index 0000000..c325434 --- /dev/null +++ b/backend/routes/settingsRoutes.js @@ -0,0 +1,7 @@ +import { Router } from 'express'; +import { getSettings, saveSettings } from '../controllers/settingsController.js'; + +const router = Router(); +router.get('/', getSettings); +router.post('/', saveSettings); +export default router; diff --git a/backend/routes/statsRoutes.js b/backend/routes/statsRoutes.js new file mode 100644 index 0000000..4484775 --- /dev/null +++ b/backend/routes/statsRoutes.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { getDaily, getMonthly, getOverview } from '../controllers/statsController.js'; + +const router = Router(); +router.get('/overview', getOverview); +router.get('/daily', getDaily); +router.get('/monthly', getMonthly); +export default router; diff --git a/backend/routes/videoRoutes.js b/backend/routes/videoRoutes.js new file mode 100644 index 0000000..2f002f4 --- /dev/null +++ b/backend/routes/videoRoutes.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { createVideo, deleteVideo, getVideoById, getVideos, searchVideos, updateVideo } from '../controllers/videoController.js'; + +const router = Router(); + +router.get('/', getVideos); +router.get('/:id', getVideoById); +router.post('/', createVideo); +router.put('/:id', updateVideo); +router.delete('/:id', deleteVideo); + +export default router; diff --git a/backend/seed/seedData.js b/backend/seed/seedData.js new file mode 100644 index 0000000..6ea6dc5 --- /dev/null +++ b/backend/seed/seedData.js @@ -0,0 +1,56 @@ +import 'dotenv/config'; +import { connectDB } from '../config/db.js'; +import { Video } from '../models/Video.js'; +import { Category } from '../models/Category.js'; +import { Tag } from '../models/Tag.js'; +import { Ad } from '../models/Ad.js'; +import { Announcement } from '../models/Announcement.js'; +import { Setting } from '../models/Setting.js'; + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/video_site'; + +const categories = ['Trending', 'Featured', 'Action', 'Drama', 'Music']; +const tags = ['hot', 'hd', 'exclusive', 'trending', 'viral', 'night', 'cinema']; +const videos = Array.from({ length: 10 }).map((_, index) => ({ + title: `Sample Video ${index + 1}`, + description: `Demo description for sample video ${index + 1}.`, + thumbnail: `https://picsum.photos/seed/video-${index + 1}/640/360`, + videoUrl: index % 2 === 0 ? 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4' : 'https://www.youtube.com/embed/dQw4w9WgXcQ', + type: index % 2 === 0 ? 'mp4' : 'embed', + views: 1000 + index * 137, + tags: [tags[index % tags.length], tags[(index + 2) % tags.length]], + category: categories[index % categories.length], + createdAt: new Date(Date.now() - index * 86400000) +})); + +const ads = [ + { image: 'https://picsum.photos/seed/header-ad/1200/180', link: 'https://example.com/header', position: 'header', active: true }, + { image: 'https://picsum.photos/seed/middle-ad/1200/180', link: 'https://example.com/middle', position: 'middle', active: true }, + { image: 'https://picsum.photos/seed/footer-ad/1200/180', link: 'https://example.com/footer', position: 'footer', active: true }, + { image: 'https://picsum.photos/seed/popup-ad/1280/720', link: 'https://example.com/popup', position: 'popup', active: true } +]; + +async function seed() { + await connectDB(MONGODB_URI); + + await Promise.all([ + Video.deleteMany({}), + Category.deleteMany({}), + Tag.deleteMany({}), + Ad.deleteMany({}), + Announcement.deleteMany({}), + Setting.deleteMany({}) + ]); + + await Category.insertMany(categories.map((name) => ({ name }))); + await Tag.insertMany(tags.map((name) => ({ name }))); + await Video.insertMany(videos); + await Ad.insertMany(ads); + await Announcement.create({ title: 'Tonight\'s Featured Drop', content: 'New videos are live now. Browse the latest uploads and trending picks.', active: true }); + await Setting.create({ siteName: 'NightFlix', logo: 'https://picsum.photos/seed/logo/120/40', primaryColor: '#e50914', popupAdsEnabled: true }); + + console.log('Seed complete'); + process.exit(0); +} + +seed(); diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..999c5d5 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,46 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import morgan from 'morgan'; +import { connectDB } from './config/db.js'; +import videoRoutes from './routes/videoRoutes.js'; +import searchRoutes from './routes/searchRoutes.js'; +import commentRoutes from './routes/commentRoutes.js'; +import { categoryRouter, tagRouter } from './routes/metaRoutes.js'; +import statsRoutes from './routes/statsRoutes.js'; +import adRoutes from './routes/adRoutes.js'; +import announcementRoutes from './routes/announcementRoutes.js'; +import settingsRoutes from './routes/settingsRoutes.js'; + +const app = express(); +const PORT = process.env.PORT || 3000; +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/video_site'; + +app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173' })); +app.use(express.json()); +app.use(morgan('dev')); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +app.use('/api/videos', videoRoutes); +app.use('/api/search', searchRoutes); +app.use('/api/comments', commentRoutes); +app.use('/api/categories', categoryRouter); +app.use('/api/tags', tagRouter); +app.use('/api/stats', statsRoutes); +app.use('/api/ads', adRoutes); +app.use('/api/announcement', announcementRoutes); +app.use('/api/settings', settingsRoutes); + +app.use((error, req, res, next) => { + console.error(error); + res.status(500).json({ message: 'Internal server error' }); +}); + +connectDB(MONGODB_URI).then(() => { + app.listen(PORT, () => { + console.log(`Backend running on port ${PORT}`); + }); +}); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6708a38 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + NightFlix + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..650a170 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "video-site-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a3b79d5 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { api } from './api/client'; +import { Layout } from './components/Layout'; +import { PopupAd } from './components/PopupAd'; +import { HomePage } from './pages/HomePage'; +import { VideoDetailPage } from './pages/VideoDetailPage'; +import { UploadPage } from './pages/UploadPage'; +import { AdminPage } from './pages/AdminPage'; + +function VideoDetailRoute({ fetchVideoDetail, videoDetail, loadingVideo }) { + const { id } = useParams(); + useEffect(() => { + fetchVideoDetail(id); + }, [id]); + return fetchVideoDetail(id)} />; +} + +export default function App() { + const [videos, setVideos] = useState([]); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); + const [ads, setAds] = useState([]); + const [announcement, setAnnouncement] = useState(null); + const [settings, setSettings] = useState(null); + const [stats, setStats] = useState(null); + const [daily, setDaily] = useState([]); + const [monthly, setMonthly] = useState([]); + const [loadingVideos, setLoadingVideos] = useState(true); + const [loadingVideo, setLoadingVideo] = useState(false); + const [loadingStats, setLoadingStats] = useState(true); + const [videoDetail, setVideoDetail] = useState(null); + const [search, setSearch] = useState(''); + const location = useLocation(); + const navigate = useNavigate(); + const query = useMemo(() => new URLSearchParams(location.search), [location.search]); + const selectedCategory = query.get('category') || ''; + const selectedTag = query.get('tag') || ''; + + const loadInitial = async () => { + setLoadingVideos(true); + const params = {}; + if (selectedCategory) params.category = selectedCategory; + if (selectedTag) params.tag = selectedTag; + if (search.trim()) params.q = search.trim(); + + const [videosRes, categoriesRes, tagsRes, adsRes, announcementRes, settingsRes] = await Promise.all([ + api.get('/videos', { params }), + api.get('/categories'), + api.get('/tags'), + api.get('/ads', { params: { active: true } }), + api.get('/announcement'), + api.get('/settings') + ]); + + setVideos(videosRes.data); + setCategories(categoriesRes.data); + setTags(tagsRes.data); + setAds(adsRes.data); + setAnnouncement(announcementRes.data); + setSettings(settingsRes.data); + setLoadingVideos(false); + }; + + const loadStats = async () => { + setLoadingStats(true); + const [overviewRes, dailyRes, monthlyRes] = await Promise.all([ + api.get('/stats/overview'), + api.get('/stats/daily'), + api.get('/stats/monthly') + ]); + setStats(overviewRes.data); + setDaily(dailyRes.data); + setMonthly(monthlyRes.data); + setLoadingStats(false); + }; + + const fetchVideoDetail = async (id) => { + setLoadingVideo(true); + const response = await api.get(`/videos/${id}`); + setVideoDetail(response.data); + setLoadingVideo(false); + }; + + useEffect(() => { + loadInitial(); + }, [selectedCategory, selectedTag, search]); + + useEffect(() => { + loadStats(); + }, []); + + const setFilter = (key, value) => { + const next = new URLSearchParams(location.search); + if (value) next.set(key, value); + else next.delete(key); + navigate({ pathname: '/', search: next.toString() }); + }; + + return ( + + ad.position === 'popup')} enabled={Boolean(settings?.popupAdsEnabled)} /> + + setFilter('category', value)} selectedTag={selectedTag} onTagChange={(value) => setFilter('tag', value)} announcement={announcement} ads={ads} />} /> + } /> + } /> + { loadInitial(); loadStats(); }} />} /> + + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..463a0fc --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,5 @@ +import axios from 'axios'; + +export const api = axios.create({ + baseURL: 'http://localhost:3000/api' +}); diff --git a/frontend/src/components/AdBanner.jsx b/frontend/src/components/AdBanner.jsx new file mode 100644 index 0000000..f9dc6f4 --- /dev/null +++ b/frontend/src/components/AdBanner.jsx @@ -0,0 +1,9 @@ +export function AdBanner({ ad }) { + if (!ad?.active) return null; + + return ( + + {`${ad.position} + + ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..88e4b99 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,26 @@ +import { Link, NavLink } from 'react-router-dom'; + +export function Layout({ settings, search, onSearchChange, children }) { + return ( +
+
+ + {settings?.logo ? {settings.siteName} : null} + {settings?.siteName || 'NightFlix'} + + onSearchChange(event.target.value)} + /> + +
+
{children}
+
+ ); +} diff --git a/frontend/src/components/PopupAd.jsx b/frontend/src/components/PopupAd.jsx new file mode 100644 index 0000000..c37fd2b --- /dev/null +++ b/frontend/src/components/PopupAd.jsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +export function PopupAd({ ad, enabled }) { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!enabled || !ad?.active) return; + const alreadySeen = sessionStorage.getItem('popupSeen'); + if (!alreadySeen) { + setOpen(true); + sessionStorage.setItem('popupSeen', 'true'); + } + }, [ad, enabled]); + + if (!open || !ad?.active || !enabled) return null; + + return ( +
setOpen(false)}> +
event.stopPropagation()}> + + + Popup ad + +
+
+ ); +} diff --git a/frontend/src/components/Skeletons.jsx b/frontend/src/components/Skeletons.jsx new file mode 100644 index 0000000..141cadd --- /dev/null +++ b/frontend/src/components/Skeletons.jsx @@ -0,0 +1,17 @@ +export function VideoGridSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+ ); +} + +export function ChartSkeleton() { + return
; +} diff --git a/frontend/src/components/VideoCard.jsx b/frontend/src/components/VideoCard.jsx new file mode 100644 index 0000000..c3c82bf --- /dev/null +++ b/frontend/src/components/VideoCard.jsx @@ -0,0 +1,16 @@ +import { Link } from 'react-router-dom'; + +export function VideoCard({ video }) { + return ( + +
+ {video.title} +
+
+

{video.title}

+

{video.views.toLocaleString()} views

+ {new Date(video.createdAt).toLocaleDateString()} +
+ + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..1f82009 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './styles/global.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +); diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx new file mode 100644 index 0000000..ddb2dce --- /dev/null +++ b/frontend/src/pages/AdminPage.jsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo, useState } from 'react'; +import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'; +import { api } from '../api/client'; +import { ChartSkeleton } from '../components/Skeletons'; + +function CrudList({ title, items, fields, onCreate, onUpdate, onDelete, initialState }) { + const [form, setForm] = useState(initialState); + const [editingId, setEditingId] = useState(''); + + const submit = async (event) => { + event.preventDefault(); + if (editingId) { + await onUpdate(editingId, form); + } else { + await onCreate(form); + } + setForm(initialState); + setEditingId(''); + }; + + return ( +
+

{title}

+
+ {fields.map((field) => ( +
diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..b3ff6b1 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,55 @@ + + + + + + + Admin Dashboard + + + +
+ +
+
+
Total Videos
+
Total Views
+
+
+

Daily Views

+
DateViews
+
+
+

Monthly Views

+
MonthViews
+
+
+

Quick Overview

+

Videos: · Categories: · Tags: · Ads: · Announcement: · Primary color:

+
+
+
+ + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..6c1dbb7 --- /dev/null +++ b/admin/login.php @@ -0,0 +1,36 @@ + + + + + + + Admin Login + + + + + + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..edf7460 --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,5 @@ + +

Admin Panel

+

Logged in as

+ + diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..f9c4db0 --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,12 @@ +prepare('UPDATE settings SET site_name=:site_name, logo=:logo, primary_color=:primary_color, popup_ads_enabled=:popup_ads_enabled WHERE id=:id'); + $stmt->execute(['site_name' => trim($_POST['site_name']), 'logo' => trim($_POST['logo']), 'primary_color' => trim($_POST['primary_color']), 'popup_ads_enabled' => !empty($_POST['popup_ads_enabled']) ? 1 : 0, 'id' => $current['id']]); + header('Location: settings.php'); exit; +} +$settings = site_settings(); +?> +Settings

Settings

diff --git a/admin/taxonomy.php b/admin/taxonomy.php new file mode 100644 index 0000000..73483dd --- /dev/null +++ b/admin/taxonomy.php @@ -0,0 +1,43 @@ +prepare('SELECT * FROM categories WHERE id=:id'); + $stmt->execute(['id' => (int) $_GET['edit_category']]); + $editCategory = $stmt->fetch(); +} +if (isset($_GET['edit_tag'])) { + $stmt = $pdo->prepare('SELECT * FROM tags WHERE id=:id'); + $stmt->execute(['id' => (int) $_GET['edit_tag']]); + $editTag = $stmt->fetch(); +} +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($_POST['entity'] === 'category') { + if (!empty($_POST['id'])) { + $stmt = $pdo->prepare('UPDATE categories SET name=:name, description=:description WHERE id=:id'); + $stmt->execute(['name' => trim($_POST['name']), 'description' => trim($_POST['description']), 'id' => (int) $_POST['id']]); + } else { + $stmt = $pdo->prepare('INSERT INTO categories (name, description) VALUES (:name, :description)'); + $stmt->execute(['name' => trim($_POST['name']), 'description' => trim($_POST['description'])]); + } + } + if ($_POST['entity'] === 'tag') { + if (!empty($_POST['id'])) { + $stmt = $pdo->prepare('UPDATE tags SET name=:name WHERE id=:id'); + $stmt->execute(['name' => trim($_POST['name']), 'id' => (int) $_POST['id']]); + } else { + $stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (:name)'); + $stmt->execute(['name' => trim($_POST['name'])]); + } + } + header('Location: taxonomy.php'); + exit; +} +if (isset($_GET['delete_category'])) { $pdo->prepare('DELETE FROM categories WHERE id=:id')->execute(['id' => (int) $_GET['delete_category']]); header('Location: taxonomy.php'); exit; } +if (isset($_GET['delete_tag'])) { $pdo->prepare('DELETE FROM tags WHERE id=:id')->execute(['id' => (int) $_GET['delete_tag']]); header('Location: taxonomy.php'); exit; } +$categories = get_categories(); +$tags = get_tags(); +?> +Taxonomy

Categories

NameDescriptionAction
Edit | Delete

Tags

NameAction
Edit | Delete
diff --git a/admin/videos.php b/admin/videos.php new file mode 100644 index 0000000..8dc837f --- /dev/null +++ b/admin/videos.php @@ -0,0 +1,22 @@ +prepare('DELETE FROM videos WHERE id = :id'); + $stmt->execute(['id' => (int) $_GET['delete']]); + header('Location: videos.php'); + exit; +} +$editing = null; +if (isset($_GET['edit'])) { + $editing = fetch_video((int) $_GET['edit']); +} +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = !empty($_POST['id']) ? (int) $_POST['id'] : null; + save_video($_POST, $id); + header('Location: videos.php'); + exit; +} +$videos = fetch_videos(); +?> +Manage Videos

Video Management

TitleCategoryViewsActions
Edit | Delete
diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..f8f0040 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,64 @@ +:root { + --primary-color: #e50914; + --bg: #050505; + --panel: #111111; + --border: #262626; + --muted: #aaaaaa; + --text: #f6f6f6; + color-scheme: dark; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); } +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +.container { width: min(1280px, calc(100% - 2rem)); margin: 0 auto; } +.site-header { position: sticky; top: 0; z-index: 30; background: rgba(0,0,0,.96); border-bottom: 1px solid var(--border); } +.nav-row { display: flex; align-items: center; gap: 1rem; padding: 1rem 0; } +.brand { display: flex; align-items: center; gap: .75rem; font-size: 1.2rem; font-weight: 700; } +.brand-logo { width: 42px; height: 42px; border-radius: 10px; object-fit: cover; } +.search-form { flex: 1; } +.search-form input, .admin-form input, .admin-form textarea, .admin-form select, .comment-form textarea { width: 100%; background: #161616; color: #fff; border: 1px solid var(--border); border-radius: 14px; padding: .9rem 1rem; } +.top-links, .chip-row, .admin-nav { display: flex; flex-wrap: wrap; gap: .75rem; } +.top-links a, .chip, .btn-primary, .admin-nav a { padding: .7rem 1rem; border-radius: 999px; border: 1px solid var(--border); background: #181818; transition: .2s ease; } +.top-links a:hover, .chip:hover, .chip.active, .btn-primary, .admin-nav a:hover { background: var(--primary-color); color: #fff; } +.main-content, .admin-main { display: grid; gap: 1rem; padding: 1.25rem 0 2rem; } +.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 20px; padding: 1rem; box-shadow: 0 20px 40px rgba(0,0,0,.18); } +.nested-panel { background: #151515; } +.banner-ad img { border-radius: 18px; } +.video-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; } +.video-card { background: #101010; border: 1px solid var(--border); border-radius: 20px; overflow: hidden; transition: transform .25s ease, box-shadow .25s ease; } +.video-card:hover { transform: translateY(-6px); box-shadow: 0 18px 30px rgba(229,9,20,.18); } +.thumb-wrap { aspect-ratio: 16 / 9; overflow: hidden; } +.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; transition: transform .3s ease; } +.video-card:hover .thumb-wrap img { transform: scale(1.08); } +.video-meta-card { padding: .95rem; display: grid; gap: .35rem; } +.video-meta-card h3, .related-card h3, .panel h1, .panel h2 { margin: 0; } +.video-meta-card p, .video-meta-card span, .muted { color: var(--muted); } +.filters { display: grid; gap: .75rem; } +.detail-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 1rem; } +.player { width: 100%; border: 0; border-radius: 18px; background: #000; min-height: 420px; } +.related-card { display: grid; grid-template-columns: 120px 1fr; gap: .75rem; padding: .75rem 0; border-bottom: 1px solid var(--border); } +.related-card img { width: 120px; height: 68px; object-fit: cover; border-radius: 10px; } +.comment-form, .admin-form, .form-panel { display: grid; gap: .9rem; } +.comment-list { display: grid; gap: .75rem; } +.comment-item { background: #161616; border-radius: 14px; padding: .85rem 1rem; border: 1px solid var(--border); } +.popup-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.82); display: grid; place-items: center; padding: 1rem; } +.popup-card { position: relative; max-width: 920px; width: 100%; } +.popup-card img { border-radius: 24px; } +.popup-close { position: absolute; top: .75rem; right: .75rem; width: 42px; height: 42px; border: 0; border-radius: 999px; background: rgba(0,0,0,.8); color: #fff; font-size: 1.4rem; cursor: pointer; } +.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 1rem; } +.login-card { width: min(460px, 100%); } +.admin-layout { display: grid; grid-template-columns: 280px 1fr; gap: 1rem; width: min(1380px, calc(100% - 2rem)); margin: 1rem auto; } +.admin-sidebar { height: fit-content; position: sticky; top: 1rem; } +.stats-grid, .split-panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; } +.stat-card strong { display: block; font-size: 2rem; margin-top: .4rem; } +.data-table { width: 100%; border-collapse: collapse; } +.data-table th, .data-table td { padding: .85rem; border-bottom: 1px solid var(--border); text-align: left; } +.inline-check { display: flex; align-items: center; gap: .65rem; } +.success { color: #89ffaf; } +.error { color: #ff9898; } +@media (max-width: 900px) { + .nav-row, .admin-layout, .detail-layout { grid-template-columns: 1fr; display: grid; } + .top-links { justify-content: flex-start; } + .player { min-height: 260px; } +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..c2174c6 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,23 @@ +document.addEventListener('DOMContentLoaded', function () { + var popup = document.getElementById('popupAd'); + if (popup && !sessionStorage.getItem('popupSeen')) { + popup.hidden = false; + sessionStorage.setItem('popupSeen', '1'); + } + + document.querySelectorAll('[data-close-popup]').forEach(function (button) { + button.addEventListener('click', function () { + if (popup) { + popup.hidden = true; + } + }); + }); + + if (popup) { + popup.addEventListener('click', function (event) { + if (event.target === popup) { + popup.hidden = true; + } + }); + } +}); diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 54a20f3..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -PORT=3000 -MONGODB_URI=mongodb://127.0.0.1:27017/video_site -FRONTEND_URL=http://localhost:5173 diff --git a/backend/config/db.js b/backend/config/db.js deleted file mode 100644 index 3b9a7b9..0000000 --- a/backend/config/db.js +++ /dev/null @@ -1,11 +0,0 @@ -import mongoose from 'mongoose'; - -export async function connectDB(uri) { - try { - await mongoose.connect(uri); - console.log('MongoDB connected'); - } catch (error) { - console.error('MongoDB connection error:', error.message); - process.exit(1); - } -} diff --git a/backend/controllers/adController.js b/backend/controllers/adController.js deleted file mode 100644 index c4c4e59..0000000 --- a/backend/controllers/adController.js +++ /dev/null @@ -1,25 +0,0 @@ -import { Ad } from '../models/Ad.js'; - -export async function getAds(req, res) { - const filter = {}; - if (req.query.active === 'true') filter.active = true; - const ads = await Ad.find(filter).sort({ createdAt: -1 }); - res.json(ads); -} - -export async function createAd(req, res) { - const ad = await Ad.create(req.body); - res.status(201).json(ad); -} - -export async function updateAd(req, res) { - const ad = await Ad.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); - if (!ad) return res.status(404).json({ message: 'Ad not found' }); - res.json(ad); -} - -export async function deleteAd(req, res) { - const ad = await Ad.findByIdAndDelete(req.params.id); - if (!ad) return res.status(404).json({ message: 'Ad not found' }); - res.json({ message: 'Ad deleted' }); -} diff --git a/backend/controllers/announcementController.js b/backend/controllers/announcementController.js deleted file mode 100644 index 240e36f..0000000 --- a/backend/controllers/announcementController.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Announcement } from '../models/Announcement.js'; - -export async function getAnnouncement(req, res) { - const announcement = await Announcement.findOne({ active: true }).sort({ updatedAt: -1 }); - res.json(announcement); -} - -export async function createAnnouncement(req, res) { - const announcement = await Announcement.create(req.body); - res.status(201).json(announcement); -} - -export async function updateAnnouncement(req, res) { - const announcement = await Announcement.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); - if (!announcement) return res.status(404).json({ message: 'Announcement not found' }); - res.json(announcement); -} - -export async function deleteAnnouncement(req, res) { - const announcement = await Announcement.findByIdAndDelete(req.params.id); - if (!announcement) return res.status(404).json({ message: 'Announcement not found' }); - res.json({ message: 'Announcement deleted' }); -} diff --git a/backend/controllers/commentController.js b/backend/controllers/commentController.js deleted file mode 100644 index 85d951d..0000000 --- a/backend/controllers/commentController.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Comment } from '../models/Comment.js'; - -export async function createComment(req, res) { - const { videoId, text } = req.body; - if (!videoId || !text?.trim()) { - return res.status(400).json({ message: 'videoId and text are required' }); - } - - const comment = await Comment.create({ videoId, text: text.trim() }); - res.status(201).json(comment); -} diff --git a/backend/controllers/metaController.js b/backend/controllers/metaController.js deleted file mode 100644 index 50a24b1..0000000 --- a/backend/controllers/metaController.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Category } from '../models/Category.js'; -import { Tag } from '../models/Tag.js'; - -export async function listCategories(req, res) { - const categories = await Category.find().sort({ name: 1 }); - res.json(categories); -} - -export async function createCategory(req, res) { - const category = await Category.create(req.body); - res.status(201).json(category); -} - -export async function updateCategory(req, res) { - const category = await Category.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); - if (!category) return res.status(404).json({ message: 'Category not found' }); - res.json(category); -} - -export async function deleteCategory(req, res) { - const category = await Category.findByIdAndDelete(req.params.id); - if (!category) return res.status(404).json({ message: 'Category not found' }); - res.json({ message: 'Category deleted' }); -} - -export async function listTags(req, res) { - const tags = await Tag.find().sort({ name: 1 }); - res.json(tags); -} - -export async function createTag(req, res) { - const tag = await Tag.create(req.body); - res.status(201).json(tag); -} - -export async function updateTag(req, res) { - const tag = await Tag.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); - if (!tag) return res.status(404).json({ message: 'Tag not found' }); - res.json(tag); -} - -export async function deleteTag(req, res) { - const tag = await Tag.findByIdAndDelete(req.params.id); - if (!tag) return res.status(404).json({ message: 'Tag not found' }); - res.json({ message: 'Tag deleted' }); -} diff --git a/backend/controllers/settingsController.js b/backend/controllers/settingsController.js deleted file mode 100644 index 9ee7c31..0000000 --- a/backend/controllers/settingsController.js +++ /dev/null @@ -1,18 +0,0 @@ -import { Setting } from '../models/Setting.js'; - -export async function getSettings(req, res) { - const settings = await Setting.findOne().sort({ updatedAt: -1 }); - res.json(settings); -} - -export async function saveSettings(req, res) { - const existing = await Setting.findOne(); - if (existing) { - Object.assign(existing, req.body); - await existing.save(); - return res.json(existing); - } - - const settings = await Setting.create(req.body); - res.status(201).json(settings); -} diff --git a/backend/controllers/statsController.js b/backend/controllers/statsController.js deleted file mode 100644 index 080892d..0000000 --- a/backend/controllers/statsController.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Video } from '../models/Video.js'; -import { ViewLog } from '../models/ViewLog.js'; - -export async function getOverview(req, res) { - const [totalVideos, totalViewsAgg] = await Promise.all([ - Video.countDocuments(), - Video.aggregate([{ $group: { _id: null, totalViews: { $sum: '$views' } } }]) - ]); - - res.json({ - totalVideos, - totalViews: totalViewsAgg[0]?.totalViews || 0 - }); -} - -export async function getDaily(req, res) { - const data = await ViewLog.aggregate([ - { - $group: { - _id: { - year: { $year: '$createdAt' }, - month: { $month: '$createdAt' }, - day: { $dayOfMonth: '$createdAt' } - }, - count: { $sum: 1 } - } - }, - { $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } } - ]); - - res.json(data.map((item) => ({ date: `${item._id.year}-${String(item._id.month).padStart(2, '0')}-${String(item._id.day).padStart(2, '0')}`, count: item.count }))); -} - -export async function getMonthly(req, res) { - const data = await ViewLog.aggregate([ - { - $group: { - _id: { - year: { $year: '$createdAt' }, - month: { $month: '$createdAt' } - }, - count: { $sum: 1 } - } - }, - { $sort: { '_id.year': 1, '_id.month': 1 } } - ]); - - res.json(data.map((item) => ({ month: `${item._id.year}-${String(item._id.month).padStart(2, '0')}`, count: item.count }))); -} diff --git a/backend/controllers/videoController.js b/backend/controllers/videoController.js deleted file mode 100644 index 87560e0..0000000 --- a/backend/controllers/videoController.js +++ /dev/null @@ -1,118 +0,0 @@ -import mongoose from 'mongoose'; -import { Video } from '../models/Video.js'; -import { Comment } from '../models/Comment.js'; -import { ViewLog } from '../models/ViewLog.js'; -import { Tag } from '../models/Tag.js'; -import { Category } from '../models/Category.js'; - -function normalizeTags(tags) { - if (Array.isArray(tags)) return tags.map((tag) => String(tag).trim()).filter(Boolean); - if (typeof tags === 'string') { - return tags.split(',').map((tag) => tag.trim()).filter(Boolean); - } - return []; -} - -async function syncMetadata(category, tags) { - if (category) { - await Category.findOneAndUpdate( - { name: category.trim() }, - { $setOnInsert: { name: category.trim() } }, - { upsert: true, new: true } - ); - } - - if (tags.length) { - await Promise.all( - tags.map((name) => - Tag.findOneAndUpdate({ name }, { $setOnInsert: { name } }, { upsert: true, new: true }) - ) - ); - } -} - -export async function getVideos(req, res) { - const { category, tag, q } = req.query; - const filter = {}; - - if (category) filter.category = category; - if (tag) filter.tags = tag; - if (q) { - filter.$or = [ - { title: { $regex: q, $options: 'i' } }, - { tags: { $elemMatch: { $regex: q, $options: 'i' } } } - ]; - } - - const videos = await Video.find(filter).sort({ createdAt: -1 }); - res.json(videos); -} - -export async function getVideoById(req, res) { - const { id } = req.params; - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ message: 'Invalid video id' }); - } - - const video = await Video.findById(id); - if (!video) { - return res.status(404).json({ message: 'Video not found' }); - } - - video.views += 1; - await video.save(); - - await ViewLog.create({ - videoId: video._id, - ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown' - }); - - const comments = await Comment.find({ videoId: video._id }).sort({ createdAt: -1 }); - const relatedVideos = await Video.find({ - _id: { $ne: video._id }, - $or: [{ category: video.category }, { tags: { $in: video.tags } }] - }) - .limit(8) - .sort({ views: -1, createdAt: -1 }); - - return res.json({ ...video.toObject(), comments, relatedVideos }); -} - -export async function createVideo(req, res) { - const payload = req.body; - const tags = normalizeTags(payload.tags); - const video = await Video.create({ ...payload, tags }); - await syncMetadata(video.category, tags); - res.status(201).json(video); -} - -export async function updateVideo(req, res) { - const { id } = req.params; - const payload = req.body; - const tags = normalizeTags(payload.tags); - const video = await Video.findByIdAndUpdate(id, { ...payload, tags }, { new: true, runValidators: true }); - if (!video) return res.status(404).json({ message: 'Video not found' }); - await syncMetadata(video.category, tags); - res.json(video); -} - -export async function deleteVideo(req, res) { - const { id } = req.params; - const video = await Video.findByIdAndDelete(id); - if (!video) return res.status(404).json({ message: 'Video not found' }); - await Comment.deleteMany({ videoId: id }); - await ViewLog.deleteMany({ videoId: id }); - res.json({ message: 'Video deleted' }); -} - -export async function searchVideos(req, res) { - const q = req.query.q || ''; - const videos = await Video.find({ - $or: [ - { title: { $regex: q, $options: 'i' } }, - { tags: { $elemMatch: { $regex: q, $options: 'i' } } } - ] - }).sort({ createdAt: -1 }); - - res.json(videos); -} diff --git a/backend/models/Ad.js b/backend/models/Ad.js deleted file mode 100644 index cad855b..0000000 --- a/backend/models/Ad.js +++ /dev/null @@ -1,13 +0,0 @@ -import mongoose from 'mongoose'; - -const adSchema = new mongoose.Schema( - { - image: { type: String, required: true }, - link: { type: String, required: true }, - position: { type: String, enum: ['popup', 'header', 'middle', 'footer'], required: true }, - active: { type: Boolean, default: true } - }, - { timestamps: true } -); - -export const Ad = mongoose.model('Ad', adSchema); diff --git a/backend/models/Announcement.js b/backend/models/Announcement.js deleted file mode 100644 index ccf9792..0000000 --- a/backend/models/Announcement.js +++ /dev/null @@ -1,12 +0,0 @@ -import mongoose from 'mongoose'; - -const announcementSchema = new mongoose.Schema( - { - title: { type: String, required: true }, - content: { type: String, required: true }, - active: { type: Boolean, default: true } - }, - { timestamps: true } -); - -export const Announcement = mongoose.model('Announcement', announcementSchema); diff --git a/backend/models/Category.js b/backend/models/Category.js deleted file mode 100644 index 2b4bc60..0000000 --- a/backend/models/Category.js +++ /dev/null @@ -1,11 +0,0 @@ -import mongoose from 'mongoose'; - -const categorySchema = new mongoose.Schema( - { - name: { type: String, required: true, unique: true, trim: true }, - description: { type: String, default: '' } - }, - { timestamps: true } -); - -export const Category = mongoose.model('Category', categorySchema); diff --git a/backend/models/Comment.js b/backend/models/Comment.js deleted file mode 100644 index 44be812..0000000 --- a/backend/models/Comment.js +++ /dev/null @@ -1,11 +0,0 @@ -import mongoose from 'mongoose'; - -const commentSchema = new mongoose.Schema( - { - videoId: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, - text: { type: String, required: true, trim: true } - }, - { timestamps: true } -); - -export const Comment = mongoose.model('Comment', commentSchema); diff --git a/backend/models/Setting.js b/backend/models/Setting.js deleted file mode 100644 index 9f6dc30..0000000 --- a/backend/models/Setting.js +++ /dev/null @@ -1,13 +0,0 @@ -import mongoose from 'mongoose'; - -const settingSchema = new mongoose.Schema( - { - siteName: { type: String, default: 'NightFlix' }, - logo: { type: String, default: '' }, - primaryColor: { type: String, default: '#e50914' }, - popupAdsEnabled: { type: Boolean, default: true } - }, - { timestamps: true } -); - -export const Setting = mongoose.model('Setting', settingSchema); diff --git a/backend/models/Tag.js b/backend/models/Tag.js deleted file mode 100644 index 2eb4824..0000000 --- a/backend/models/Tag.js +++ /dev/null @@ -1,10 +0,0 @@ -import mongoose from 'mongoose'; - -const tagSchema = new mongoose.Schema( - { - name: { type: String, required: true, unique: true, trim: true } - }, - { timestamps: true } -); - -export const Tag = mongoose.model('Tag', tagSchema); diff --git a/backend/models/Video.js b/backend/models/Video.js deleted file mode 100644 index da2074d..0000000 --- a/backend/models/Video.js +++ /dev/null @@ -1,17 +0,0 @@ -import mongoose from 'mongoose'; - -const videoSchema = new mongoose.Schema( - { - title: { type: String, required: true, trim: true }, - description: { type: String, default: '' }, - thumbnail: { type: String, required: true }, - videoUrl: { type: String, required: true }, - type: { type: String, enum: ['mp4', 'embed'], required: true }, - views: { type: Number, default: 0 }, - tags: [{ type: String, trim: true }], - category: { type: String, required: true, trim: true } - }, - { timestamps: true } -); - -export const Video = mongoose.model('Video', videoSchema); diff --git a/backend/models/ViewLog.js b/backend/models/ViewLog.js deleted file mode 100644 index 2bb05e7..0000000 --- a/backend/models/ViewLog.js +++ /dev/null @@ -1,11 +0,0 @@ -import mongoose from 'mongoose'; - -const viewLogSchema = new mongoose.Schema( - { - videoId: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, - ip: { type: String, default: 'unknown' } - }, - { timestamps: true } -); - -export const ViewLog = mongoose.model('ViewLog', viewLogSchema); diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index b437b40..0000000 --- a/backend/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "video-site-backend", - "version": "1.0.0", - "type": "module", - "scripts": { - "dev": "nodemon server.js", - "start": "node server.js", - "seed": "node seed/seedData.js" - }, - "dependencies": { - "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "mongoose": "^8.6.1", - "morgan": "^1.10.0" - }, - "devDependencies": { - "nodemon": "^3.1.4" - } -} diff --git a/backend/routes/adRoutes.js b/backend/routes/adRoutes.js deleted file mode 100644 index a6c085e..0000000 --- a/backend/routes/adRoutes.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Router } from 'express'; -import { createAd, deleteAd, getAds, updateAd } from '../controllers/adController.js'; - -const router = Router(); -router.get('/', getAds); -router.post('/', createAd); -router.put('/:id', updateAd); -router.delete('/:id', deleteAd); -export default router; diff --git a/backend/routes/announcementRoutes.js b/backend/routes/announcementRoutes.js deleted file mode 100644 index aee8ca5..0000000 --- a/backend/routes/announcementRoutes.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Router } from 'express'; -import { createAnnouncement, deleteAnnouncement, getAnnouncement, updateAnnouncement } from '../controllers/announcementController.js'; - -const router = Router(); -router.get('/', getAnnouncement); -router.post('/', createAnnouncement); -router.put('/:id', updateAnnouncement); -router.delete('/:id', deleteAnnouncement); -export default router; diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js deleted file mode 100644 index 4027a3f..0000000 --- a/backend/routes/commentRoutes.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Router } from 'express'; -import { createComment } from '../controllers/commentController.js'; - -const router = Router(); -router.post('/', createComment); -export default router; diff --git a/backend/routes/metaRoutes.js b/backend/routes/metaRoutes.js deleted file mode 100644 index ba57116..0000000 --- a/backend/routes/metaRoutes.js +++ /dev/null @@ -1,25 +0,0 @@ -import { Router } from 'express'; -import { - createCategory, - createTag, - deleteCategory, - deleteTag, - listCategories, - listTags, - updateCategory, - updateTag -} from '../controllers/metaController.js'; - -const categoryRouter = Router(); -categoryRouter.get('/', listCategories); -categoryRouter.post('/', createCategory); -categoryRouter.put('/:id', updateCategory); -categoryRouter.delete('/:id', deleteCategory); - -const tagRouter = Router(); -tagRouter.get('/', listTags); -tagRouter.post('/', createTag); -tagRouter.put('/:id', updateTag); -tagRouter.delete('/:id', deleteTag); - -export { categoryRouter, tagRouter }; diff --git a/backend/routes/searchRoutes.js b/backend/routes/searchRoutes.js deleted file mode 100644 index 8983fdc..0000000 --- a/backend/routes/searchRoutes.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Router } from 'express'; -import { searchVideos } from '../controllers/videoController.js'; - -const router = Router(); - -router.get('/', searchVideos); - -export default router; diff --git a/backend/routes/settingsRoutes.js b/backend/routes/settingsRoutes.js deleted file mode 100644 index c325434..0000000 --- a/backend/routes/settingsRoutes.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Router } from 'express'; -import { getSettings, saveSettings } from '../controllers/settingsController.js'; - -const router = Router(); -router.get('/', getSettings); -router.post('/', saveSettings); -export default router; diff --git a/backend/routes/statsRoutes.js b/backend/routes/statsRoutes.js deleted file mode 100644 index 4484775..0000000 --- a/backend/routes/statsRoutes.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Router } from 'express'; -import { getDaily, getMonthly, getOverview } from '../controllers/statsController.js'; - -const router = Router(); -router.get('/overview', getOverview); -router.get('/daily', getDaily); -router.get('/monthly', getMonthly); -export default router; diff --git a/backend/routes/videoRoutes.js b/backend/routes/videoRoutes.js deleted file mode 100644 index 2f002f4..0000000 --- a/backend/routes/videoRoutes.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Router } from 'express'; -import { createVideo, deleteVideo, getVideoById, getVideos, searchVideos, updateVideo } from '../controllers/videoController.js'; - -const router = Router(); - -router.get('/', getVideos); -router.get('/:id', getVideoById); -router.post('/', createVideo); -router.put('/:id', updateVideo); -router.delete('/:id', deleteVideo); - -export default router; diff --git a/backend/seed/seedData.js b/backend/seed/seedData.js deleted file mode 100644 index 6ea6dc5..0000000 --- a/backend/seed/seedData.js +++ /dev/null @@ -1,56 +0,0 @@ -import 'dotenv/config'; -import { connectDB } from '../config/db.js'; -import { Video } from '../models/Video.js'; -import { Category } from '../models/Category.js'; -import { Tag } from '../models/Tag.js'; -import { Ad } from '../models/Ad.js'; -import { Announcement } from '../models/Announcement.js'; -import { Setting } from '../models/Setting.js'; - -const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/video_site'; - -const categories = ['Trending', 'Featured', 'Action', 'Drama', 'Music']; -const tags = ['hot', 'hd', 'exclusive', 'trending', 'viral', 'night', 'cinema']; -const videos = Array.from({ length: 10 }).map((_, index) => ({ - title: `Sample Video ${index + 1}`, - description: `Demo description for sample video ${index + 1}.`, - thumbnail: `https://picsum.photos/seed/video-${index + 1}/640/360`, - videoUrl: index % 2 === 0 ? 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4' : 'https://www.youtube.com/embed/dQw4w9WgXcQ', - type: index % 2 === 0 ? 'mp4' : 'embed', - views: 1000 + index * 137, - tags: [tags[index % tags.length], tags[(index + 2) % tags.length]], - category: categories[index % categories.length], - createdAt: new Date(Date.now() - index * 86400000) -})); - -const ads = [ - { image: 'https://picsum.photos/seed/header-ad/1200/180', link: 'https://example.com/header', position: 'header', active: true }, - { image: 'https://picsum.photos/seed/middle-ad/1200/180', link: 'https://example.com/middle', position: 'middle', active: true }, - { image: 'https://picsum.photos/seed/footer-ad/1200/180', link: 'https://example.com/footer', position: 'footer', active: true }, - { image: 'https://picsum.photos/seed/popup-ad/1280/720', link: 'https://example.com/popup', position: 'popup', active: true } -]; - -async function seed() { - await connectDB(MONGODB_URI); - - await Promise.all([ - Video.deleteMany({}), - Category.deleteMany({}), - Tag.deleteMany({}), - Ad.deleteMany({}), - Announcement.deleteMany({}), - Setting.deleteMany({}) - ]); - - await Category.insertMany(categories.map((name) => ({ name }))); - await Tag.insertMany(tags.map((name) => ({ name }))); - await Video.insertMany(videos); - await Ad.insertMany(ads); - await Announcement.create({ title: 'Tonight\'s Featured Drop', content: 'New videos are live now. Browse the latest uploads and trending picks.', active: true }); - await Setting.create({ siteName: 'NightFlix', logo: 'https://picsum.photos/seed/logo/120/40', primaryColor: '#e50914', popupAdsEnabled: true }); - - console.log('Seed complete'); - process.exit(0); -} - -seed(); diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index 999c5d5..0000000 --- a/backend/server.js +++ /dev/null @@ -1,46 +0,0 @@ -import 'dotenv/config'; -import express from 'express'; -import cors from 'cors'; -import morgan from 'morgan'; -import { connectDB } from './config/db.js'; -import videoRoutes from './routes/videoRoutes.js'; -import searchRoutes from './routes/searchRoutes.js'; -import commentRoutes from './routes/commentRoutes.js'; -import { categoryRouter, tagRouter } from './routes/metaRoutes.js'; -import statsRoutes from './routes/statsRoutes.js'; -import adRoutes from './routes/adRoutes.js'; -import announcementRoutes from './routes/announcementRoutes.js'; -import settingsRoutes from './routes/settingsRoutes.js'; - -const app = express(); -const PORT = process.env.PORT || 3000; -const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/video_site'; - -app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173' })); -app.use(express.json()); -app.use(morgan('dev')); - -app.get('/api/health', (req, res) => { - res.json({ status: 'ok' }); -}); - -app.use('/api/videos', videoRoutes); -app.use('/api/search', searchRoutes); -app.use('/api/comments', commentRoutes); -app.use('/api/categories', categoryRouter); -app.use('/api/tags', tagRouter); -app.use('/api/stats', statsRoutes); -app.use('/api/ads', adRoutes); -app.use('/api/announcement', announcementRoutes); -app.use('/api/settings', settingsRoutes); - -app.use((error, req, res, next) => { - console.error(error); - res.status(500).json({ message: 'Internal server error' }); -}); - -connectDB(MONGODB_URI).then(() => { - app.listen(PORT, () => { - console.log(`Backend running on port ${PORT}`); - }); -}); diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..2c8da62 --- /dev/null +++ b/database.sql @@ -0,0 +1,126 @@ +CREATE DATABASE IF NOT EXISTS video_site CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE video_site; + +CREATE TABLE IF NOT EXISTS categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS videos ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + thumbnail VARCHAR(500) NOT NULL, + video_url VARCHAR(500) NOT NULL, + type ENUM('mp4','embed') NOT NULL DEFAULT 'mp4', + views INT NOT NULL DEFAULT 0, + tags TEXT, + category VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + video_id INT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_comments_video FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS view_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + video_id INT NOT NULL, + ip VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_view_logs_video FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ads ( + id INT AUTO_INCREMENT PRIMARY KEY, + image VARCHAR(500) NOT NULL, + link VARCHAR(500) NOT NULL, + position ENUM('popup','header','middle','footer') NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS announcements ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + site_name VARCHAR(255) NOT NULL, + logo VARCHAR(500) DEFAULT '', + primary_color VARCHAR(20) NOT NULL DEFAULT '#e50914', + popup_ads_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS admin ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT IGNORE INTO categories (id, name, description) VALUES +(1, 'Trending', 'Most watched videos'), +(2, 'Featured', 'Editor picks'), +(3, 'Action', 'Fast-paced picks'), +(4, 'Drama', 'Story-rich uploads'), +(5, 'Music', 'Concert and clip content'); + +INSERT IGNORE INTO tags (id, name) VALUES +(1, 'hot'), (2, 'hd'), (3, 'viral'), (4, 'exclusive'), (5, 'night'), (6, 'cinema'), (7, 'trending'); + +INSERT IGNORE INTO videos (id, title, description, thumbnail, video_url, type, views, tags, category, created_at) VALUES +(1, 'Night Drop 1', 'Sample featured upload for the PHP version.', 'https://picsum.photos/seed/php-1/640/360', 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4', 'mp4', 1450, 'hot,hd,trending', 'Trending', NOW() - INTERVAL 1 DAY), +(2, 'Night Drop 2', 'Embed demo content.', 'https://picsum.photos/seed/php-2/640/360', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'embed', 1280, 'viral,exclusive', 'Featured', NOW() - INTERVAL 2 DAY), +(3, 'Night Drop 3', 'Action collection sample.', 'https://picsum.photos/seed/php-3/640/360', 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4', 'mp4', 980, 'night,cinema', 'Action', NOW() - INTERVAL 3 DAY), +(4, 'Night Drop 4', 'Drama sample video.', 'https://picsum.photos/seed/php-4/640/360', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'embed', 1660, 'hot,viral', 'Drama', NOW() - INTERVAL 4 DAY), +(5, 'Night Drop 5', 'Music sample video.', 'https://picsum.photos/seed/php-5/640/360', 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4', 'mp4', 2015, 'hd,cinema', 'Music', NOW() - INTERVAL 5 DAY), +(6, 'Night Drop 6', 'Another featured upload.', 'https://picsum.photos/seed/php-6/640/360', 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4', 'mp4', 1111, 'exclusive,trending', 'Featured', NOW() - INTERVAL 6 DAY), +(7, 'Night Drop 7', 'Action demo.', 'https://picsum.photos/seed/php-7/640/360', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'embed', 890, 'night,hot', 'Action', NOW() - INTERVAL 7 DAY), +(8, 'Night Drop 8', 'Trending clip.', 'https://picsum.photos/seed/php-8/640/360', 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4', 'mp4', 3200, 'viral,hd', 'Trending', NOW() - INTERVAL 8 DAY), +(9, 'Night Drop 9', 'Drama embed clip.', 'https://picsum.photos/seed/php-9/640/360', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'embed', 734, 'exclusive,cinema', 'Drama', NOW() - INTERVAL 9 DAY), +(10, 'Night Drop 10', 'Music upload demo.', 'https://picsum.photos/seed/php-10/640/360', 'https://samplelib.com/lib/preview/mp4/sample-5s.mp4', 'mp4', 1744, 'trending,night', 'Music', NOW() - INTERVAL 10 DAY); + +INSERT IGNORE INTO ads (id, image, link, position, active) VALUES +(1, 'https://picsum.photos/seed/php-header/1200/180', 'https://example.com/header', 'header', 1), +(2, 'https://picsum.photos/seed/php-middle/1200/180', 'https://example.com/middle', 'middle', 1), +(3, 'https://picsum.photos/seed/php-footer/1200/180', 'https://example.com/footer', 'footer', 1), +(4, 'https://picsum.photos/seed/php-popup/1280/720', 'https://example.com/popup', 'popup', 1); + +INSERT IGNORE INTO announcements (id, title, content, active) VALUES +(1, 'Featured Release Tonight', 'New clips and trending videos are now live. Explore the latest uploads and editor picks.', 1); + +INSERT IGNORE INTO settings (id, site_name, logo, primary_color, popup_ads_enabled) VALUES +(1, 'NightFlix PHP', 'https://picsum.photos/seed/php-logo/120/40', '#e50914', 1); + +INSERT IGNORE INTO admin (id, username, password) VALUES +(1, 'admin', '$2y$12$6oyCc.4n3JyP8/fu9NhP3Oitk1ljqOs/qRgFcTZJagOULa8AMe1Nu'); + +INSERT INTO view_logs (video_id, ip, created_at) VALUES +(1, '127.0.0.1', NOW() - INTERVAL 1 DAY), +(1, '127.0.0.1', NOW() - INTERVAL 1 DAY), +(2, '127.0.0.1', NOW() - INTERVAL 2 DAY), +(3, '127.0.0.1', NOW() - INTERVAL 2 DAY), +(3, '127.0.0.1', NOW() - INTERVAL 3 DAY), +(4, '127.0.0.1', NOW() - INTERVAL 4 DAY), +(5, '127.0.0.1', NOW() - INTERVAL 15 DAY), +(6, '127.0.0.1', NOW() - INTERVAL 33 DAY), +(7, '127.0.0.1', NOW() - INTERVAL 34 DAY), +(8, '127.0.0.1', NOW() - INTERVAL 60 DAY); diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 6708a38..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - NightFlix - - -
- - - diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 650a170..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "video-site-frontend", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "axios": "^1.7.4", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.1", - "recharts": "^2.12.7" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.2" - } -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index a3b79d5..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'; -import { api } from './api/client'; -import { Layout } from './components/Layout'; -import { PopupAd } from './components/PopupAd'; -import { HomePage } from './pages/HomePage'; -import { VideoDetailPage } from './pages/VideoDetailPage'; -import { UploadPage } from './pages/UploadPage'; -import { AdminPage } from './pages/AdminPage'; - -function VideoDetailRoute({ fetchVideoDetail, videoDetail, loadingVideo }) { - const { id } = useParams(); - useEffect(() => { - fetchVideoDetail(id); - }, [id]); - return fetchVideoDetail(id)} />; -} - -export default function App() { - const [videos, setVideos] = useState([]); - const [categories, setCategories] = useState([]); - const [tags, setTags] = useState([]); - const [ads, setAds] = useState([]); - const [announcement, setAnnouncement] = useState(null); - const [settings, setSettings] = useState(null); - const [stats, setStats] = useState(null); - const [daily, setDaily] = useState([]); - const [monthly, setMonthly] = useState([]); - const [loadingVideos, setLoadingVideos] = useState(true); - const [loadingVideo, setLoadingVideo] = useState(false); - const [loadingStats, setLoadingStats] = useState(true); - const [videoDetail, setVideoDetail] = useState(null); - const [search, setSearch] = useState(''); - const location = useLocation(); - const navigate = useNavigate(); - const query = useMemo(() => new URLSearchParams(location.search), [location.search]); - const selectedCategory = query.get('category') || ''; - const selectedTag = query.get('tag') || ''; - - const loadInitial = async () => { - setLoadingVideos(true); - const params = {}; - if (selectedCategory) params.category = selectedCategory; - if (selectedTag) params.tag = selectedTag; - if (search.trim()) params.q = search.trim(); - - const [videosRes, categoriesRes, tagsRes, adsRes, announcementRes, settingsRes] = await Promise.all([ - api.get('/videos', { params }), - api.get('/categories'), - api.get('/tags'), - api.get('/ads', { params: { active: true } }), - api.get('/announcement'), - api.get('/settings') - ]); - - setVideos(videosRes.data); - setCategories(categoriesRes.data); - setTags(tagsRes.data); - setAds(adsRes.data); - setAnnouncement(announcementRes.data); - setSettings(settingsRes.data); - setLoadingVideos(false); - }; - - const loadStats = async () => { - setLoadingStats(true); - const [overviewRes, dailyRes, monthlyRes] = await Promise.all([ - api.get('/stats/overview'), - api.get('/stats/daily'), - api.get('/stats/monthly') - ]); - setStats(overviewRes.data); - setDaily(dailyRes.data); - setMonthly(monthlyRes.data); - setLoadingStats(false); - }; - - const fetchVideoDetail = async (id) => { - setLoadingVideo(true); - const response = await api.get(`/videos/${id}`); - setVideoDetail(response.data); - setLoadingVideo(false); - }; - - useEffect(() => { - loadInitial(); - }, [selectedCategory, selectedTag, search]); - - useEffect(() => { - loadStats(); - }, []); - - const setFilter = (key, value) => { - const next = new URLSearchParams(location.search); - if (value) next.set(key, value); - else next.delete(key); - navigate({ pathname: '/', search: next.toString() }); - }; - - return ( - - ad.position === 'popup')} enabled={Boolean(settings?.popupAdsEnabled)} /> - - setFilter('category', value)} selectedTag={selectedTag} onTagChange={(value) => setFilter('tag', value)} announcement={announcement} ads={ads} />} /> - } /> - } /> - { loadInitial(); loadStats(); }} />} /> - - - ); -} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js deleted file mode 100644 index 463a0fc..0000000 --- a/frontend/src/api/client.js +++ /dev/null @@ -1,5 +0,0 @@ -import axios from 'axios'; - -export const api = axios.create({ - baseURL: 'http://localhost:3000/api' -}); diff --git a/frontend/src/components/AdBanner.jsx b/frontend/src/components/AdBanner.jsx deleted file mode 100644 index f9dc6f4..0000000 --- a/frontend/src/components/AdBanner.jsx +++ /dev/null @@ -1,9 +0,0 @@ -export function AdBanner({ ad }) { - if (!ad?.active) return null; - - return ( - - {`${ad.position} - - ); -} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx deleted file mode 100644 index 88e4b99..0000000 --- a/frontend/src/components/Layout.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Link, NavLink } from 'react-router-dom'; - -export function Layout({ settings, search, onSearchChange, children }) { - return ( -
-
- - {settings?.logo ? {settings.siteName} : null} - {settings?.siteName || 'NightFlix'} - - onSearchChange(event.target.value)} - /> - -
-
{children}
-
- ); -} diff --git a/frontend/src/components/PopupAd.jsx b/frontend/src/components/PopupAd.jsx deleted file mode 100644 index c37fd2b..0000000 --- a/frontend/src/components/PopupAd.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useState } from 'react'; - -export function PopupAd({ ad, enabled }) { - const [open, setOpen] = useState(false); - - useEffect(() => { - if (!enabled || !ad?.active) return; - const alreadySeen = sessionStorage.getItem('popupSeen'); - if (!alreadySeen) { - setOpen(true); - sessionStorage.setItem('popupSeen', 'true'); - } - }, [ad, enabled]); - - if (!open || !ad?.active || !enabled) return null; - - return ( -
setOpen(false)}> -
event.stopPropagation()}> - - - Popup ad - -
-
- ); -} diff --git a/frontend/src/components/Skeletons.jsx b/frontend/src/components/Skeletons.jsx deleted file mode 100644 index 141cadd..0000000 --- a/frontend/src/components/Skeletons.jsx +++ /dev/null @@ -1,17 +0,0 @@ -export function VideoGridSkeleton() { - return ( -
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
- ))} -
- ); -} - -export function ChartSkeleton() { - return
; -} diff --git a/frontend/src/components/VideoCard.jsx b/frontend/src/components/VideoCard.jsx deleted file mode 100644 index c3c82bf..0000000 --- a/frontend/src/components/VideoCard.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Link } from 'react-router-dom'; - -export function VideoCard({ video }) { - return ( - -
- {video.title} -
-
-

{video.title}

-

{video.views.toLocaleString()} views

- {new Date(video.createdAt).toLocaleDateString()} -
- - ); -} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index 1f82009..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import App from './App'; -import './styles/global.css'; - -ReactDOM.createRoot(document.getElementById('root')).render( - - - - - -); diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx deleted file mode 100644 index ddb2dce..0000000 --- a/frontend/src/pages/AdminPage.jsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'; -import { api } from '../api/client'; -import { ChartSkeleton } from '../components/Skeletons'; - -function CrudList({ title, items, fields, onCreate, onUpdate, onDelete, initialState }) { - const [form, setForm] = useState(initialState); - const [editingId, setEditingId] = useState(''); - - const submit = async (event) => { - event.preventDefault(); - if (editingId) { - await onUpdate(editingId, form); - } else { - await onCreate(form); - } - setForm(initialState); - setEditingId(''); - }; - - return ( -
-

{title}

-
- {fields.map((field) => ( - + + + + + + +
+
+ diff --git a/public_html/video.php b/public_html/video.php new file mode 100644 index 0000000..df38cb6 --- /dev/null +++ b/public_html/video.php @@ -0,0 +1,62 @@ + +
+
+ + + + + +

+

views ·

+

+
+ + # + +
+
+

Comments

+ + +
+
+ +
+

+ +
+ +
+
+ +
+