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:
+5
-1
@@ -6,12 +6,16 @@
|
|||||||
"subscribers": [
|
"subscribers": [
|
||||||
"ma",
|
"ma",
|
||||||
"miles"
|
"miles"
|
||||||
|
],
|
||||||
|
"watchHistory": [
|
||||||
|
1757455619505
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1757455788998,
|
"id": 1757455788998,
|
||||||
"username": "ma",
|
"username": "ma",
|
||||||
"password": "$2b$10$FX01zPi0FXvHcXtcsX6t0.w0QHxIkkByhlX8tdx4fboQpnXtn.9a2",
|
"password": "$2b$10$FX01zPi0FXvHcXtcsX6t0.w0QHxIkkByhlX8tdx4fboQpnXtn.9a2",
|
||||||
"subscribers": []
|
"subscribers": [],
|
||||||
|
"watchHistory": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+6
-19
@@ -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,
|
"id": 1757455619505,
|
||||||
"title": "SysDVR_2025_08_31_16_54_21.mp4",
|
"title": "SysDVR_2025_08_31_16_54_21.mp4",
|
||||||
"filename": "1757455619060.mp4",
|
"filename": "1757455619060.mp4",
|
||||||
"uploader": "miles",
|
"uploader": "miles",
|
||||||
"likes": [
|
"likes": [
|
||||||
"ma"
|
"ma",
|
||||||
|
"miles"
|
||||||
],
|
],
|
||||||
"comments": [
|
"comments": [
|
||||||
{
|
{
|
||||||
@@ -33,7 +18,8 @@
|
|||||||
"text": "hi"
|
"text": "hi"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"thumbnail": "1757455619233.png"
|
"thumbnail": "1757455619233.png",
|
||||||
|
"_score": 0.2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1757455624902,
|
"id": 1757455624902,
|
||||||
@@ -54,6 +40,7 @@
|
|||||||
"text": "no"
|
"text": "no"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"thumbnail": "1757455624790.png"
|
"thumbnail": "1757455624790.png",
|
||||||
|
"_score": 0.4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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 --------------------
|
// -------------------- Load Videos on Homepage --------------------
|
||||||
async function loadVideos() {
|
async function loadVideos() {
|
||||||
try {
|
try {
|
||||||
@@ -13,9 +112,20 @@ async function loadVideos() {
|
|||||||
<img src="/thumbnails/${v.thumbnail}" width="120">
|
<img src="/thumbnails/${v.thumbnail}" width="120">
|
||||||
<a href="watch.html?id=${v.id}">${v.title}</a>
|
<a href="watch.html?id=${v.id}">${v.title}</a>
|
||||||
<small>by <a href="user.html?username=${v.uploader}">${v.uploader}</a></small>
|
<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);
|
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) {
|
} catch (err) {
|
||||||
console.error("Error loading videos:", err);
|
console.error("Error loading videos:", err);
|
||||||
}
|
}
|
||||||
@@ -93,6 +203,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// Homepage video list
|
// Homepage video list
|
||||||
loadVideos();
|
loadVideos();
|
||||||
|
|
||||||
|
// Recommended sidebar
|
||||||
|
loadRecommendedIndex();
|
||||||
|
|
||||||
// Login form
|
// Login form
|
||||||
const loginForm = document.getElementById("loginForm");
|
const loginForm = document.getElementById("loginForm");
|
||||||
if (loginForm) loginForm.addEventListener("submit", loginUser);
|
if (loginForm) loginForm.addEventListener("submit", loginUser);
|
||||||
@@ -108,4 +221,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// Upload form
|
// Upload form
|
||||||
const uploadForm = document.getElementById("uploadForm");
|
const uploadForm = document.getElementById("uploadForm");
|
||||||
if (uploadForm) uploadForm.addEventListener("submit", uploadVideo);
|
if (uploadForm) uploadForm.addEventListener("submit", uploadVideo);
|
||||||
|
|
||||||
|
// Render watch later/history on all pages
|
||||||
|
renderWatchLater();
|
||||||
|
renderWatchHistory();
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-3
@@ -6,12 +6,22 @@
|
|||||||
<script src="app.js" defer></script>
|
<script src="app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div style="display: flex; gap: 2em; align-items: flex-start;">
|
||||||
|
<main style="flex: 2;">
|
||||||
<h1>MiniTube</h1>
|
<h1>MiniTube</h1>
|
||||||
|
|
||||||
<!-- Upload button -->
|
<!-- Upload button -->
|
||||||
<p><a href="upload.html"><button>Upload Video</button></a></p>
|
<p><a href="upload.html"><button>Upload Video</button></a></p>
|
||||||
<P> <a href="login.html"><button>login</button></a></P>
|
<p><a href="login.html"><button>login</button></a></p>
|
||||||
|
|
||||||
<ul id="videoList"></ul>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,6 +14,39 @@
|
|||||||
<button type="submit">Upload</button>
|
<button type="submit">Upload</button>
|
||||||
</form>
|
</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>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 2em; align-items: flex-start;">
|
||||||
|
<main style="flex: 2;">
|
||||||
<h1 id="videoTitle"></h1>
|
<h1 id="videoTitle"></h1>
|
||||||
<video id="videoPlayer" controls width="640"></video>
|
<video id="videoPlayer" controls width="640"></video>
|
||||||
<p>Uploader: <a id="uploaderLink"></a></p>
|
<p>Uploader: <a id="uploaderLink"></a></p>
|
||||||
@@ -15,11 +18,13 @@
|
|||||||
<button id="subBtn"></button>
|
<button id="subBtn"></button>
|
||||||
<p id="subCount"></p>
|
<p id="subCount"></p>
|
||||||
|
|
||||||
|
<div id="thumbSection" style="display:none">
|
||||||
<h2>Change Thumbnail</h2>
|
<h2>Change Thumbnail</h2>
|
||||||
<form id="thumbForm" enctype="multipart/form-data">
|
<form id="thumbForm" enctype="multipart/form-data">
|
||||||
<input type="file" name="thumbnail" accept="image/*" required>
|
<input type="file" name="thumbnail" accept="image/*" required>
|
||||||
<button type="submit">Update Thumbnail</button>
|
<button type="submit">Update Thumbnail</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Comments</h2>
|
<h2>Comments</h2>
|
||||||
<ul id="comments"></ul>
|
<ul id="comments"></ul>
|
||||||
@@ -27,12 +32,33 @@
|
|||||||
<input type="text" name="text" placeholder="Write a comment" required>
|
<input type="text" name="text" placeholder="Write a comment" required>
|
||||||
<button type="submit">Post</button>
|
<button type="submit">Post</button>
|
||||||
</form>
|
</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>
|
<script>
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const videoId = params.get("id");
|
const videoId = params.get("id");
|
||||||
let uploader = "";
|
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() {
|
async function loadVideo() {
|
||||||
const res = await fetch(`/api/videos/${videoId}`);
|
const res = await fetch(`/api/videos/${videoId}`);
|
||||||
const video = await res.json();
|
const video = await res.json();
|
||||||
@@ -48,8 +74,68 @@
|
|||||||
const userRes = await fetch(`/api/user/${uploader}`);
|
const userRes = await fetch(`/api/user/${uploader}`);
|
||||||
const user = await userRes.json();
|
const user = await userRes.json();
|
||||||
document.getElementById("subCount").textContent = `Subscribers: ${user.subscribers}`;
|
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) {
|
function renderComments(comments) {
|
||||||
const list = document.getElementById("comments");
|
const list = document.getElementById("comments");
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const multer = require("multer");
|
const multer = require("multer");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -17,6 +20,9 @@ const videoDbFile = "./data/videos.json";
|
|||||||
let users = fs.existsSync(userDbFile) ? JSON.parse(fs.readFileSync(userDbFile)) : [];
|
let users = fs.existsSync(userDbFile) ? JSON.parse(fs.readFileSync(userDbFile)) : [];
|
||||||
let videos = fs.existsSync(videoDbFile) ? JSON.parse(fs.readFileSync(videoDbFile)) : [];
|
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
|
// Middleware
|
||||||
app.use(express.static("public"));
|
app.use(express.static("public"));
|
||||||
app.use("/videos", express.static("videos"));
|
app.use("/videos", express.static("videos"));
|
||||||
@@ -102,13 +108,33 @@ app.post("/api/logout", (req, res) => {
|
|||||||
// ----------------- Videos -----------------
|
// ----------------- Videos -----------------
|
||||||
app.get("/api/videos", (req, res) => res.json(videos));
|
app.get("/api/videos", (req, res) => res.json(videos));
|
||||||
|
|
||||||
app.post("/api/upload", requireLogin, uploadVideo.single("video"), (req, res) => {
|
// Support tags, title, and custom thumbnail
|
||||||
const user = users.find(u => u.id === req.session.userId);
|
const uploadFields = uploadVideo.fields([
|
||||||
const videoPath = req.file.path;
|
{ name: 'video', maxCount: 1 },
|
||||||
const thumbFilename = `${Date.now()}.png`;
|
{ name: 'thumbnail', maxCount: 1 }
|
||||||
const thumbPath = path.join("thumbnails", thumbFilename);
|
]);
|
||||||
|
|
||||||
// Generate thumbnail at 2 seconds
|
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)
|
ffmpeg(videoPath)
|
||||||
.screenshots({
|
.screenshots({
|
||||||
timestamps: ['2'],
|
timestamps: ['2'],
|
||||||
@@ -116,31 +142,84 @@ app.post("/api/upload", requireLogin, uploadVideo.single("video"), (req, res) =>
|
|||||||
folder: 'thumbnails',
|
folder: 'thumbnails',
|
||||||
size: '320x240'
|
size: '320x240'
|
||||||
})
|
})
|
||||||
.on('end', () => {
|
.on('end', finishUpload)
|
||||||
|
.on('error', err => {
|
||||||
|
console.error("Thumbnail generation error:", err);
|
||||||
|
finishUpload("Video uploaded but thumbnail generation failed.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishUpload(thumbnailError) {
|
||||||
const newVideo = {
|
const newVideo = {
|
||||||
id: Date.now(),
|
id: videoId,
|
||||||
title: req.file.originalname,
|
title,
|
||||||
filename: req.file.filename,
|
filename: videoFile.filename,
|
||||||
uploader: user.username,
|
uploader: user.username,
|
||||||
likes: [],
|
likes: [],
|
||||||
comments: [],
|
comments: [],
|
||||||
thumbnail: thumbFilename
|
thumbnail: thumbFilename,
|
||||||
|
tags
|
||||||
};
|
};
|
||||||
videos.push(newVideo);
|
videos.push(newVideo);
|
||||||
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
||||||
|
if (thumbnailError) {
|
||||||
|
res.status(500).json({ error: thumbnailError, video: newVideo });
|
||||||
|
} else {
|
||||||
res.json({ success: true, video: newVideo });
|
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." });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Get video and record watch history if logged in
|
||||||
app.get("/api/videos/:id", (req, res) => {
|
app.get("/api/videos/:id", (req, res) => {
|
||||||
const video = videos.find(v => v.id == req.params.id);
|
const video = videos.find(v => v.id == req.params.id);
|
||||||
if (!video) return res.status(404).json({ error: "Not found" });
|
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);
|
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
|
// Change thumbnail
|
||||||
app.post("/api/videos/:id/thumbnail", requireLogin, uploadThumb.single("thumbnail"), (req, res) => {
|
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 });
|
res.json({ success: true, subscribers: target.subscribers.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// ----------------- Start -----------------
|
// ----------------- Start -----------------
|
||||||
app.listen(PORT, () =>
|
app.listen(PORT, () =>
|
||||||
console.log(`MiniTube running at http://localhost:${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 });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user