Implement user watch history and personalized video recommendations; enhance upload functionality with tags and custom thumbnails; add admin panel for user and video management.

This commit is contained in:
2025-09-09 21:01:12 -04:00
parent 243a2a528d
commit 7490615433
9 changed files with 468 additions and 69 deletions
+5 -1
View File
@@ -6,12 +6,16 @@
"subscribers": [
"ma",
"miles"
],
"watchHistory": [
1757455619505
]
},
{
"id": 1757455788998,
"username": "ma",
"password": "$2b$10$FX01zPi0FXvHcXtcsX6t0.w0QHxIkkByhlX8tdx4fboQpnXtn.9a2",
"subscribers": []
"subscribers": [],
"watchHistory": []
}
]
+6 -19
View File
@@ -1,27 +1,12 @@
[
{
"id": 1757455491733,
"title": "yapping-yapping-level-today.mp4",
"filename": "1757455491493.mp4",
"uploader": "miles",
"likes": [
"miles"
],
"comments": [
{
"user": "miles",
"text": "hi"
}
],
"thumbnail": "1757455491496.png"
},
{
"id": 1757455619505,
"title": "SysDVR_2025_08_31_16_54_21.mp4",
"filename": "1757455619060.mp4",
"uploader": "miles",
"likes": [
"ma"
"ma",
"miles"
],
"comments": [
{
@@ -33,7 +18,8 @@
"text": "hi"
}
],
"thumbnail": "1757455619233.png"
"thumbnail": "1757455619233.png",
"_score": 0.2
},
{
"id": 1757455624902,
@@ -54,6 +40,7 @@
"text": "no"
}
],
"thumbnail": "1757455624790.png"
"thumbnail": "1757455624790.png",
"_score": 0.4
}
]
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Admin Panel - MiniTube</title>
<link rel="stylesheet" href="style.css">
<script src="admin.js" defer></script>
</head>
<body>
<h1>Admin Panel</h1>
<section>
<h2>Delete User Account</h2>
<form id="deleteUserForm">
<input type="text" id="deleteUsername" placeholder="Username" required>
<button type="submit">Delete User</button>
</form>
<p id="userDeleteMsg"></p>
</section>
<section>
<h2>Delete Video</h2>
<form id="deleteVideoForm">
<input type="text" id="deleteVideoId" placeholder="Video ID" required>
<button type="submit">Delete Video</button>
</form>
<p id="videoDeleteMsg"></p>
</section>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
// Admin Panel JS
document.getElementById('deleteUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('deleteUsername').value.trim();
if (!username) return;
const res = await fetch('/api/admin/delete-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const data = await res.json();
document.getElementById('userDeleteMsg').textContent = data.success ? 'User deleted.' : (data.error || 'Error.');
});
document.getElementById('deleteVideoForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('deleteVideoId').value.trim();
if (!id) return;
const res = await fetch('/api/admin/delete-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await res.json();
document.getElementById('videoDeleteMsg').textContent = data.success ? 'Video deleted.' : (data.error || 'Error.');
});
+117
View File
@@ -1,3 +1,102 @@
// -------------------- Recommended Videos (Personalized) --------------------
async function loadRecommendedIndex() {
const list = document.getElementById('recommendedVideos');
if (!list) return;
let videos = [];
let res;
try {
res = await fetch('/api/recommended');
if (res.ok) {
videos = await res.json();
} else {
res = await fetch('/api/videos');
videos = await res.json();
}
} catch {
res = await fetch('/api/videos');
videos = await res.json();
}
// Show top 6
const top = videos.slice(0, 6);
list.innerHTML = '';
for (const v of top) {
const li = document.createElement('li');
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.marginBottom = '1em';
li.innerHTML = `
<a href="watch.html?id=${v.id}" style="display:flex;align-items:center;text-decoration:none;color:inherit;">
<img src="/thumbnails/${v.thumbnail}" width="80" height="60" style="object-fit:cover;border-radius:6px;margin-right:10px;">
<div>
<div style="font-weight:bold;">${v.title}</div>
<div style="font-size:0.9em;color:#aaa;">by ${v.uploader}</div>
<div style="font-size:0.9em;color:#aaa;">${v.likes?.length || 0} likes</div>
</div>
</a>
`;
list.appendChild(li);
}
}
// -------------------- Watch Later & History (localStorage) --------------------
function getWatchLater() {
return JSON.parse(localStorage.getItem('watchLater') || '[]');
}
function setWatchLater(arr) {
localStorage.setItem('watchLater', JSON.stringify(arr));
}
function addToWatchLater(video) {
let arr = getWatchLater();
if (!arr.find(v => v.id === video.id)) {
arr.unshift(video);
setWatchLater(arr.slice(0, 20));
}
renderWatchLater();
}
function removeFromWatchLater(id) {
let arr = getWatchLater().filter(v => v.id !== id);
setWatchLater(arr);
renderWatchLater();
}
function renderWatchLater() {
const list = document.getElementById('watchLaterList');
if (!list) return;
const arr = getWatchLater();
list.innerHTML = '';
arr.forEach(v => {
const li = document.createElement('li');
li.innerHTML = `<a href="watch.html?id=${v.id}">${v.title}</a> <button data-id="${v.id}" class="removeLater">✕</button>`;
list.appendChild(li);
});
list.querySelectorAll('.removeLater').forEach(btn => {
btn.onclick = e => removeFromWatchLater(btn.dataset.id);
});
}
function getWatchHistory() {
return JSON.parse(localStorage.getItem('watchHistory') || '[]');
}
function setWatchHistory(arr) {
localStorage.setItem('watchHistory', JSON.stringify(arr));
}
function addToWatchHistory(video) {
let arr = getWatchHistory();
arr = arr.filter(v => v.id !== video.id);
arr.unshift(video);
setWatchHistory(arr.slice(0, 20));
renderWatchHistory();
}
function renderWatchHistory() {
const list = document.getElementById('watchHistoryList');
if (!list) return;
const arr = getWatchHistory();
list.innerHTML = '';
arr.forEach(v => {
const li = document.createElement('li');
li.innerHTML = `<a href="watch.html?id=${v.id}">${v.title}</a>`;
list.appendChild(li);
});
}
// -------------------- Load Videos on Homepage --------------------
async function loadVideos() {
try {
@@ -13,9 +112,20 @@ async function loadVideos() {
<img src="/thumbnails/${v.thumbnail}" width="120">
<a href="watch.html?id=${v.id}">${v.title}</a>
<small>by <a href="user.html?username=${v.uploader}">${v.uploader}</a></small>
<button class="addLater" data-id="${v.id}">Watch Later</button>
`;
list.appendChild(li);
});
// Add event listeners for Watch Later
list.querySelectorAll('.addLater').forEach(btn => {
btn.onclick = async e => {
const id = btn.dataset.id;
const v = videos.find(v => v.id == id);
if (v) addToWatchLater(v);
};
});
renderWatchLater();
renderWatchHistory();
} catch (err) {
console.error("Error loading videos:", err);
}
@@ -93,6 +203,9 @@ document.addEventListener("DOMContentLoaded", () => {
// Homepage video list
loadVideos();
// Recommended sidebar
loadRecommendedIndex();
// Login form
const loginForm = document.getElementById("loginForm");
if (loginForm) loginForm.addEventListener("submit", loginUser);
@@ -108,4 +221,8 @@ document.addEventListener("DOMContentLoaded", () => {
// Upload form
const uploadForm = document.getElementById("uploadForm");
if (uploadForm) uploadForm.addEventListener("submit", uploadVideo);
// Render watch later/history on all pages
renderWatchLater();
renderWatchHistory();
});
+17 -7
View File
@@ -6,12 +6,22 @@
<script src="app.js" defer></script>
</head>
<body>
<h1>MiniTube</h1>
<!-- Upload button -->
<p><a href="upload.html"><button>Upload Video</button></a></p>
<P> <a href="login.html"><button>login</button></a></P>
<ul id="videoList"></ul>
<div style="display: flex; gap: 2em; align-items: flex-start;">
<main style="flex: 2;">
<h1>MiniTube</h1>
<!-- Upload button -->
<p><a href="upload.html"><button>Upload Video</button></a></p>
<p><a href="login.html"><button>login</button></a></p>
<ul id="videoList"></ul>
</main>
<aside style="flex: 1; min-width: 220px;">
<h2>Recommended</h2>
<ul id="recommendedVideos"></ul>
<h2>Watch Later</h2>
<ul id="watchLaterList"></ul>
<h2>Watch History</h2>
<ul id="watchHistoryList"></ul>
</aside>
</div>
</body>
</html>
+33
View File
@@ -14,6 +14,39 @@
<button type="submit">Upload</button>
</form>
<label>Title</label>
<input type="text" name="title" id="videoTitleInput" placeholder="Video Title" required>
<label>Tags (comma separated)</label>
<input type="text" name="tags" id="videoTagsInput" placeholder="e.g. music,funny,cat">
<label>Uploader</label>
<input type="text" id="uploaderInput" readonly>
<label>Video File (.mp4 or .webm)</label>
<input type="file" name="video" accept=".mp4,.webm" required>
<label><input type="checkbox" id="customThumbCheck"> Upload custom thumbnail</label>
<input type="file" name="thumbnail" id="customThumbInput" accept="image/*" style="display:none">
<button type="submit">Upload</button>
</form>
<p><a href="index.html">Back to Home</a></p>
<script>
// Show/hide custom thumbnail input
document.addEventListener('DOMContentLoaded', () => {
const check = document.getElementById('customThumbCheck');
const thumbInput = document.getElementById('customThumbInput');
check.addEventListener('change', () => {
thumbInput.style.display = check.checked ? '' : 'none';
thumbInput.required = check.checked;
});
// Fetch current user for uploader field
fetch('/api/me').then(r => r.json()).then(me => {
if (me && me.username) document.getElementById('uploaderInput').value = me.username;
});
});
</script>
</body>
</html>
+104 -18
View File
@@ -5,34 +5,60 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1 id="videoTitle"></h1>
<video id="videoPlayer" controls width="640"></video>
<p>Uploader: <a id="uploaderLink"></a></p>
<button id="likeBtn">Like</button>
<span id="likeCount"></span>
<div style="display: flex; gap: 2em; align-items: flex-start;">
<main style="flex: 2;">
<h1 id="videoTitle"></h1>
<video id="videoPlayer" controls width="640"></video>
<p>Uploader: <a id="uploaderLink"></a></p>
<button id="subBtn"></button>
<p id="subCount"></p>
<button id="likeBtn">Like</button>
<span id="likeCount"></span>
<h2>Change Thumbnail</h2>
<form id="thumbForm" enctype="multipart/form-data">
<input type="file" name="thumbnail" accept="image/*" required>
<button type="submit">Update Thumbnail</button>
</form>
<button id="subBtn"></button>
<p id="subCount"></p>
<h2>Comments</h2>
<ul id="comments"></ul>
<form id="commentForm">
<input type="text" name="text" placeholder="Write a comment" required>
<button type="submit">Post</button>
</form>
<div id="thumbSection" style="display:none">
<h2>Change Thumbnail</h2>
<form id="thumbForm" enctype="multipart/form-data">
<input type="file" name="thumbnail" accept="image/*" required>
<button type="submit">Update Thumbnail</button>
</form>
</div>
<h2>Comments</h2>
<ul id="comments"></ul>
<form id="commentForm">
<input type="text" name="text" placeholder="Write a comment" required>
<button type="submit">Post</button>
</form>
</main>
<aside style="flex: 1; min-width: 220px;">
<h2>Recommended</h2>
<ul id="recommendedVideos"></ul>
<h2>Watch Later</h2>
<ul id="watchLaterList"></ul>
<h2>Watch History</h2>
<ul id="watchHistoryList"></ul>
</aside>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const videoId = params.get("id");
let uploader = "";
async function getCurrentUser() {
try {
const res = await fetch('/api/me');
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function loadVideo() {
const res = await fetch(`/api/videos/${videoId}`);
const video = await res.json();
@@ -48,8 +74,68 @@
const userRes = await fetch(`/api/user/${uploader}`);
const user = await userRes.json();
document.getElementById("subCount").textContent = `Subscribers: ${user.subscribers}`;
// Only show Change Thumbnail if current user is uploader
const me = await getCurrentUser();
if (me && me.username === uploader) {
document.getElementById("thumbSection").style.display = "block";
} else {
document.getElementById("thumbSection").style.display = "none";
}
// Load recommended videos
loadRecommended(videoId);
}
async function loadRecommended(currentId) {
let videos = [];
let res;
try {
res = await fetch('/api/recommended');
if (res.ok) {
videos = await res.json();
} else {
// Not logged in, fallback to all videos
res = await fetch('/api/videos');
videos = await res.json();
}
} catch {
// fallback
res = await fetch('/api/videos');
videos = await res.json();
}
// Remove current video
videos = videos.filter(v => v.id != currentId);
// Show top 6
const top = videos.slice(0, 6);
const list = document.getElementById('recommendedVideos');
list.innerHTML = '';
for (const v of top) {
const li = document.createElement('li');
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.marginBottom = '1em';
li.innerHTML = `
<a href="watch.html?id=${v.id}" style="display:flex;align-items:center;text-decoration:none;color:inherit;">
<img src="/thumbnails/${v.thumbnail}" width="80" height="60" style="object-fit:cover;border-radius:6px;margin-right:10px;">
<div>
<div style="font-weight:bold;">${v.title}</div>
<div style="font-size:0.9em;color:#aaa;">by ${v.uploader}</div>
<div style="font-size:0.9em;color:#aaa;">${v.likes?.length || 0} likes</div>
</div>
</a>
`;
list.appendChild(li);
}
}
// Add /api/me endpoint to server.js if not present:
// app.get("/api/me", (req, res) => {
// if (!req.session.userId) return res.status(401).json({});
// const user = users.find(u => u.id === req.session.userId);
// if (!user) return res.status(401).json({});
// res.json({ username: user.username });
// });
function renderComments(comments) {
const list = document.getElementById("comments");
list.innerHTML = "";
+132 -24
View File
@@ -1,4 +1,7 @@
// ...existing code...
const express = require("express");
const multer = require("multer");
const fs = require("fs");
@@ -17,6 +20,9 @@ const videoDbFile = "./data/videos.json";
let users = fs.existsSync(userDbFile) ? JSON.parse(fs.readFileSync(userDbFile)) : [];
let videos = fs.existsSync(videoDbFile) ? JSON.parse(fs.readFileSync(videoDbFile)) : [];
// Ensure all users have a watchHistory array
users.forEach(u => { if (!u.watchHistory) u.watchHistory = []; });
// Middleware
app.use(express.static("public"));
app.use("/videos", express.static("videos"));
@@ -102,45 +108,118 @@ app.post("/api/logout", (req, res) => {
// ----------------- Videos -----------------
app.get("/api/videos", (req, res) => res.json(videos));
app.post("/api/upload", requireLogin, uploadVideo.single("video"), (req, res) => {
const user = users.find(u => u.id === req.session.userId);
const videoPath = req.file.path;
const thumbFilename = `${Date.now()}.png`;
const thumbPath = path.join("thumbnails", thumbFilename);
// Support tags, title, and custom thumbnail
const uploadFields = uploadVideo.fields([
{ name: 'video', maxCount: 1 },
{ name: 'thumbnail', maxCount: 1 }
]);
// Generate thumbnail at 2 seconds
ffmpeg(videoPath)
.screenshots({
timestamps: ['2'],
filename: thumbFilename,
folder: 'thumbnails',
size: '320x240'
})
.on('end', () => {
app.post("/api/upload", requireLogin, (req, res) => {
uploadFields(req, res, function (err) {
if (err) return res.status(400).json({ error: err.message });
const user = users.find(u => u.id === req.session.userId);
if (!req.files || !req.files['video'] || !req.files['video'][0]) {
return res.status(400).json({ error: "No video uploaded" });
}
const videoFile = req.files['video'][0];
const title = req.body.title || videoFile.originalname;
const tags = (req.body.tags || "").split(",").map(t => t.trim()).filter(Boolean);
const videoId = Date.now();
// If custom thumbnail provided, use it
let thumbFilename = null;
if (req.files['thumbnail'] && req.files['thumbnail'][0]) {
thumbFilename = req.files['thumbnail'][0].filename;
finishUpload();
} else {
// Auto-generate thumbnail
thumbFilename = `${videoId}.png`;
const videoPath = videoFile.path;
ffmpeg(videoPath)
.screenshots({
timestamps: ['2'],
filename: thumbFilename,
folder: 'thumbnails',
size: '320x240'
})
.on('end', finishUpload)
.on('error', err => {
console.error("Thumbnail generation error:", err);
finishUpload("Video uploaded but thumbnail generation failed.");
});
}
function finishUpload(thumbnailError) {
const newVideo = {
id: Date.now(),
title: req.file.originalname,
filename: req.file.filename,
id: videoId,
title,
filename: videoFile.filename,
uploader: user.username,
likes: [],
comments: [],
thumbnail: thumbFilename
thumbnail: thumbFilename,
tags
};
videos.push(newVideo);
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
res.json({ success: true, video: newVideo });
})
.on('error', err => {
console.error("Thumbnail generation error:", err);
res.status(500).json({ error: "Video uploaded but thumbnail generation failed." });
});
if (thumbnailError) {
res.status(500).json({ error: thumbnailError, video: newVideo });
} else {
res.json({ success: true, video: newVideo });
}
}
});
});
// Get video and record watch history if logged in
app.get("/api/videos/:id", (req, res) => {
const video = videos.find(v => v.id == req.params.id);
if (!video) return res.status(404).json({ error: "Not found" });
// Record watch history for logged-in user
if (req.session.userId) {
const user = users.find(u => u.id === req.session.userId);
if (user && !user.watchHistory.includes(video.id)) {
user.watchHistory.push(video.id);
fs.writeFileSync(userDbFile, JSON.stringify(users, null, 2));
}
}
res.json(video);
});
// Personalized recommendations based on tags and watch history
app.get("/api/recommended", requireLogin, (req, res) => {
const user = users.find(u => u.id === req.session.userId);
if (!user) return res.status(401).json([]);
// Get videos not watched yet
let unwatched = videos.filter(v => !user.watchHistory.includes(v.id));
// Get tags from recently watched videos (last 5)
let recent = user.watchHistory.slice(-5).map(id => videos.find(v => v.id === id)).filter(Boolean);
let tagCounts = {};
recent.forEach(v => {
if (Array.isArray(v.tags)) {
v.tags.forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
}
});
// Score unwatched videos by tag overlap
unwatched.forEach(v => {
v._score = 0;
if (Array.isArray(v.tags)) {
v.tags.forEach(tag => {
v._score += tagCounts[tag] || 0;
});
}
// Boost by likes
v._score += (v.likes?.length || 0) * 0.2;
});
// Sort by score, then most recent
unwatched.sort((a, b) => b._score - a._score || b.id - a.id);
res.json(unwatched.slice(0, 8));
});
// Change thumbnail
app.post("/api/videos/:id/thumbnail", requireLogin, uploadThumb.single("thumbnail"), (req, res) => {
@@ -211,7 +290,36 @@ app.post("/api/subscribe/:username", requireLogin, (req, res) => {
res.json({ success: true, subscribers: target.subscribers.length });
});
// ----------------- Start -----------------
app.listen(PORT, () =>
console.log(`MiniTube running at http://localhost:${PORT}`)
);
// ----------------- Admin Panel (MUST be at the very end) -----------------
function requireAdmin(req, res, next) {
const user = users.find(u => u.id === req.session.userId);
if (!user || !user.isAdmin) {
return res.status(403).json({ error: 'Admin only' });
}
next();
}
app.post('/api/admin/delete-user', requireLogin, requireAdmin, (req, res) => {
const { username } = req.body;
if (!username) return res.status(400).json({ error: 'Username required' });
const idx = users.findIndex(u => u.username === username);
if (idx === -1) return res.status(404).json({ error: 'User not found' });
users.splice(idx, 1);
fs.writeFileSync(userDbFile, JSON.stringify(users, null, 2));
res.json({ success: true });
});
app.post('/api/admin/delete-video', requireLogin, requireAdmin, (req, res) => {
const { id } = req.body;
const idx = videos.findIndex(v => String(v.id) === String(id));
if (idx === -1) return res.status(404).json({ error: 'Video not found' });
videos.splice(idx, 1);
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
res.json({ success: true });
});