Compare commits
	
		
			No commits in common. "main" and "dark-mode" have entirely different histories.
		
	
	
		
	
		
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -2,5 +2,3 @@ node_modules/ | ||||
| videos/* | ||||
| !videos/.gitkeep | ||||
| tmp/ | ||||
| database.sqlite3 | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										336
									
								
								index.js
									
									
									
									
									
								
							
							
						
						
									
										336
									
								
								index.js
									
									
									
									
									
								
							| @ -6,48 +6,11 @@ import path from "path"; | ||||
| import childProcess from "child_process"; | ||||
| import levenshtein from "js-levenshtein"; | ||||
| import cookieParser from "cookie-parser"; | ||||
| import { fileURLToPath } from "url"; | ||||
| import sqlite3 from "sqlite3"; | ||||
| import fs from "fs/promises"; | ||||
| 
 | ||||
| const PORT = 8000; | ||||
| import { fileURLToPath } from 'url'; | ||||
| 
 | ||||
| const users = []; | ||||
| let sessions = []; | ||||
| let videoQueue = []; | ||||
| 
 | ||||
| const db = new sqlite3.Database("database.sqlite3"); | ||||
| 
 | ||||
| function dbGet(query, ...parameters) { | ||||
|     return new Promise((resolve, reject) => db.get(query, parameters, (err, data) => err ? reject(err) : resolve(data))); | ||||
| } | ||||
| 
 | ||||
| function dbAll(query, ...parameters) { | ||||
|     return new Promise((resolve, reject) => db.all(query, parameters, (err, data) => err ? reject(err) : resolve(data))); | ||||
| } | ||||
| 
 | ||||
| function dbRun(query, ...parameters) { | ||||
|     return new Promise((resolve, reject) => db.run(query, parameters, (err, data) => err ? reject(err) : resolve(data))); | ||||
| } | ||||
| 
 | ||||
| function runCommand(cmd, args) { | ||||
|     return new Promise(resolve => { | ||||
|         const process = childProcess.spawn(cmd, args); | ||||
| 
 | ||||
|         let data = ""; | ||||
|         let error = false; | ||||
|         process.stdout.on("data", chunk => data += chunk); | ||||
|         process.stderr.on("data", chunk => { | ||||
|             error = true; | ||||
|             data += chunk; | ||||
|         }); | ||||
| 
 | ||||
|         process.on("close", () => resolve({ error, data })); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| function videoPath(id) { | ||||
|     return `videos/${id}.webm`; | ||||
| } | ||||
| const videos = []; | ||||
| 
 | ||||
| function randomString(length) { | ||||
|     const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789"; | ||||
| @ -62,8 +25,77 @@ function dirname() { | ||||
|     return path.dirname(fileURLToPath(import.meta.url)); | ||||
| } | ||||
| 
 | ||||
| const app = express(); | ||||
| app.use(cors()); | ||||
| app.use(express.json()); | ||||
| app.use(cookieParser()); | ||||
| app.use(express.urlencoded({ extended: true })); | ||||
| 
 | ||||
| app.use("/", express.static("public/")); | ||||
| app.use("/videos", express.static("videos/")); | ||||
| 
 | ||||
| app.post("/api/register", async (req, res) => { | ||||
|     const { username, password } = req.body; | ||||
|     if (typeof username !== "string" || typeof password !== "string") { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
|     const existingUser = users.find(user => user.username === username); | ||||
|     if (existingUser !== undefined) { | ||||
|         return res.status(400).json({ ok: false, error: "username taken" }); | ||||
|     } | ||||
|     const passwordHash = await bcrypt.hash(password, 10); | ||||
|     const id = users.length; | ||||
|     const user = { id, username, passwordHash }; | ||||
|     users.push(user); | ||||
|     return res.status(200).json({ ok: true, user }); | ||||
| }); | ||||
| 
 | ||||
| app.post("/api/login", async (req, res) => { | ||||
|     const { username, password } = req.body; | ||||
|     if (typeof username !== "string" || typeof password !== "string") { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
|     const user = users.find(user => user.username === username); | ||||
|     if (user === undefined) { | ||||
|         return res.status(400).json({ ok: false, error: "wrong username/password" }); | ||||
|     } | ||||
|     if (!await bcrypt.compare(password, user.passwordHash)) { | ||||
|         return res.status(400).json({ ok: false, error: "wrong username/password" }); | ||||
|     } | ||||
|     sessions = sessions.filter(session => session.userId !== user.id); | ||||
|     const token = randomString(64); | ||||
|     const session = { userId: user.id, token }; | ||||
|     sessions.push(session); | ||||
|     res.clearCookie("token"); | ||||
|     res.cookie("token", token); | ||||
|     return res.status(200).json({ ok: true, session }); | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| app.get("/api/search", async (req, res) => { | ||||
|     const page = +req.query.page || 0; | ||||
|     const search = req.query.query; | ||||
|     if (!search) { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
|     const [start, end] = [20 * page, 20 * (page + 1)]; | ||||
|     const withDistance = videos | ||||
|         .map(video => ({ dist: levenshtein(search, video.title), ...video })); | ||||
|     withDistance.sort((a, b) => a.dist - b.dist); | ||||
|     const returnedVideos = withDistance | ||||
|         .slice(start, end) | ||||
|         .map(video => { | ||||
|             const user = users.find(user => user.id === video.userId); | ||||
|             if (!user) { | ||||
|                 return { ...video, author: "[Liberal]" }; | ||||
|             } | ||||
|             return { ...video, author: user.username }; | ||||
|         }); | ||||
|     return res.status(200).json({ ok: true, videos: returnedVideos, total: videos.length }); | ||||
| }); | ||||
| 
 | ||||
| function authorized() { | ||||
|     return async (req, res, next) => { | ||||
|     return (req, res, next) => { | ||||
|         const token = (() => { | ||||
|             if (req.cookies && "token" in req.cookies) { | ||||
|                 return req.cookies["token"]; | ||||
| @ -82,7 +114,7 @@ function authorized() { | ||||
|         if (session === undefined) { | ||||
|             return res.status(400).json({ ok: false, error: "unauthorized" }); | ||||
|         } | ||||
|         const user = await dbGet("SELECT * FROM users WHERE id = ?", session.userId); | ||||
|         const user = users.find(user => user.id === session.userId); | ||||
|         if (user === undefined) { | ||||
|             throw new Error("error: session with invalid userId"); | ||||
|         } | ||||
| @ -91,225 +123,53 @@ function authorized() { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| const app = express(); | ||||
| app.use(cors()); | ||||
| app.use(express.json()); | ||||
| app.use(cookieParser()); | ||||
| app.use(express.urlencoded({ extended: true })); | ||||
| 
 | ||||
| app.use("/", express.static("public/")); | ||||
| app.use("/videos", express.static("videos/")); | ||||
| 
 | ||||
| app.post("/api/register", async (req, res) => { | ||||
|     const { username, password } = req.body; | ||||
| 
 | ||||
|     if (typeof username !== "string" || typeof password !== "string") { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
| 
 | ||||
|     const existingUser = await dbGet("SELECT * FROM users WHERE username = ?", username); | ||||
| 
 | ||||
|     if (existingUser !== undefined) { | ||||
|         return res.status(400).json({ ok: false, error: "username taken" }); | ||||
|     } | ||||
| 
 | ||||
|     const passwordHash = await bcrypt.hash(password, 10); | ||||
| 
 | ||||
|     await dbGet("INSERT INTO users (username, password) VALUES (?, ?)", username, passwordHash); | ||||
| 
 | ||||
|     return res.status(200).json({ ok: true }); | ||||
| }); | ||||
| 
 | ||||
| app.post("/api/login", async (req, res) => { | ||||
|     const { username, password } = req.body; | ||||
| 
 | ||||
|     if (typeof username !== "string" || typeof password !== "string") { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
| 
 | ||||
|     const user = await dbGet("SELECT * FROM users WHERE username = ?", username); | ||||
| 
 | ||||
|     if (user === undefined) { | ||||
|         return res.status(400).json({ ok: false, error: "wrong username/password" }); | ||||
|     } | ||||
| 
 | ||||
|     if (!await bcrypt.compare(password, user.password)) { | ||||
|         return res.status(400).json({ ok: false, error: "wrong username/password" }); | ||||
|     } | ||||
| 
 | ||||
|     sessions = sessions.filter(session => session.userId !== user.id); | ||||
| 
 | ||||
|     const token = randomString(64); | ||||
|     const session = { userId: user.id, token }; | ||||
| 
 | ||||
|     sessions.push(session); | ||||
| 
 | ||||
|     res.clearCookie("token"); | ||||
|     res.cookie("token", token); | ||||
| 
 | ||||
|     return res.status(200).json({ ok: true, session }); | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| app.get("/api/search", async (req, res) => { | ||||
|     const page = +req.query.page || 0; | ||||
|     const search = req.query.query; | ||||
| 
 | ||||
|     if (!search) { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
| 
 | ||||
|     const [start, end] = [20 * page, 20]; | ||||
|     const videos = await dbAll(` | ||||
|         SELECT videos.*, users.username AS author | ||||
|         FROM videos | ||||
|         JOIN users ON users.id = videos.user_id | ||||
|         WHERE title LIKE CONCAT('%', ?, '%') | ||||
|         LIMIT ? | ||||
|         OFFSET ? | ||||
|     `, search, end, start);
 | ||||
| 
 | ||||
|     const { total } = await dbGet("SELECT COUNT(*) AS total FROM videos"); | ||||
| 
 | ||||
|     return res.status(200).json({ ok: true, videos, total }); | ||||
| }); | ||||
| 
 | ||||
| app.get("/api/logout", authorized(), (req, res) => { | ||||
|     sessions = sessions.filter(session => session.userId !== req.user.id); | ||||
|     return res.status(200).json({ ok: true }); | ||||
| }); | ||||
| 
 | ||||
| app.post("/api/upload-video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => { | ||||
|     const { title, description } = req.body; | ||||
|     if (!title) { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
| 
 | ||||
| app.post("/api/upload_video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => { | ||||
|     const { title } = req.body; | ||||
|     if (!req.files || !req.files.video) { | ||||
|         return res.status(400).json({ ok: false, error: "bad request" }); | ||||
|     } | ||||
| 
 | ||||
|     if (req.files.video.mimetype !== "video/mp4") { | ||||
|         return res.status(415).json({ ok: false, error: "bad mimetype" }); | ||||
|         return res.status(400).json({ ok: false, error: "bad mimetype" }); | ||||
|     } | ||||
| 
 | ||||
|     const userId = req.user.id; | ||||
|     const id = randomString(4); | ||||
|     const tempPath = req.files.video.tempFilePath; | ||||
|     const newPath = path.join(dirname(), videoPath(id)); | ||||
|     const thumbnailPath = path.join(dirname(), "videos", `${id}.png`); | ||||
|     const newPath = path.join(dirname(), "videos", `${id}.mp4`); | ||||
|     console.log(newPath); | ||||
| 
 | ||||
|     const durationResult = await runCommand("ffprobe", ["-i", tempPath, "-show_format", "-loglevel", "error"]); | ||||
|     if (durationResult.error) { | ||||
|         return res.status(400).json({ ok: false, error: "invalid video file" }); | ||||
|     const exitCode = await new Promise((resolve, _reject) => { | ||||
|         const process = childProcess.spawn("HandBrakeCLI", ["-i", tempPath, "-o", newPath, "-Z", "Social 25 MB 5 Minutes 360p60"]); | ||||
|         process.stderr.on("data", (data) => { | ||||
|             console.error(data.toString()); | ||||
|         }); | ||||
|         process.on("close", (code) => { | ||||
|             resolve(code); | ||||
|         }) | ||||
|     }) | ||||
|     if (exitCode !== 0) { | ||||
|         throw new Error("handbrake failed"); | ||||
|     } | ||||
| 
 | ||||
|     const duration = parseFloat(durationResult.data.match(/duration=([.\d]+)/)[1]) * 1_000_000;; | ||||
| 
 | ||||
|     const queueItem = { | ||||
|         videoId: id, | ||||
|     const video = { | ||||
|         id, | ||||
|         userId, | ||||
|         title, | ||||
|         errors: [], | ||||
|         progress: 0, | ||||
|         duration, | ||||
|         path: newPath, | ||||
|     }; | ||||
| 
 | ||||
|     videoQueue.push(queueItem); | ||||
| 
 | ||||
|     const progressPath = path.join(dirname(), "tmp", "progress", `${id}.txt`) | ||||
|     const uploadProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-b:v", "1M", "-b:a", "192k", "-progress", progressPath, "-loglevel", "error", newPath]); | ||||
|     const thumbnailProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-ss", "00:00:01.000", "-vframes", "1", "-loglevel", "error", thumbnailPath]); | ||||
| 
 | ||||
|     uploadProcess.stderr.on("data", data => queueItem.errors.push(data.toString())); | ||||
|     thumbnailProcess.stderr.on("data", data => queueItem.errors.push(data.toString())); | ||||
| 
 | ||||
|     uploadProcess.on("close", () => { | ||||
|         if (queueItem.errors.length) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         dbRun( | ||||
|             "INSERT INTO videos (id, user_id, title, description, created_at) VALUES (?, ?, ?, ?, ?)", | ||||
|             id, userId, title, description ?? "", new Date().toISOString() | ||||
|         ); | ||||
| 
 | ||||
|         const index = videoQueue.indexOf(item => item.videoId === queueItem.videoId) | ||||
|         videoQueue.splice(index, 1); | ||||
|     }); | ||||
| 
 | ||||
|     return res.status(200).json({ ok: true }); | ||||
| }); | ||||
| 
 | ||||
| app.get("/api/video-queue", authorized(), (req, res) => { | ||||
|     const userId = req.user.id; | ||||
| 
 | ||||
|     const queue = videoQueue.filter(item => item.userId === userId); | ||||
| 
 | ||||
|     return res.status(200).json({ ok: true, queue }); | ||||
| }); | ||||
| 
 | ||||
| app.get("/api/video-info", async (req, res) => { | ||||
|     const id = req.query["id"]; | ||||
| 
 | ||||
|     const video = await dbGet(` | ||||
|         SELECT videos.id, videos.title, videos.description, videos.created_at, users.username AS author | ||||
|         FROM videos | ||||
|         JOIN users ON users.id = videos.user_id | ||||
|         WHERE videos.id = ? | ||||
|         LIMIT 1 | ||||
|     `, id);
 | ||||
| 
 | ||||
|     if (!video) { | ||||
|         return res.status(404).json({ ok: false, error: "video not found" }); | ||||
|     } | ||||
| 
 | ||||
|     video.path = path.join("/", videoPath(id)); | ||||
| 
 | ||||
|     videos.push(video); | ||||
|     return res.status(200).json({ ok: true, video }); | ||||
| }); | ||||
| }) | ||||
| 
 | ||||
| app.use((err, req, res, next) => { | ||||
|     console.error(err); | ||||
|     res.status(500).json({ ok: false, error: "server error" }) | ||||
| }); | ||||
| 
 | ||||
| async function main() { | ||||
|     (async () => { | ||||
|         const progressPath = path.join(dirname(), "tmp", "progress"); | ||||
| 
 | ||||
|         await fs.mkdir(progressPath, { recursive: true }); | ||||
| 
 | ||||
|         const watch = fs.watch(progressPath, { recursive: true }); | ||||
| 
 | ||||
|         for await (const event of watch) { | ||||
|             const videoId = event.filename.replace(".txt", ""); | ||||
|             const queueItem = videoQueue.find(item => item.videoId === videoId); | ||||
| 
 | ||||
|             if (!queueItem) { | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             fs.readFile(path.join(progressPath, event.filename), "utf-8") | ||||
|                 .then(string => { | ||||
|                     const index = string.lastIndexOf("out_time_ms="); | ||||
|                     if (index === -1) return; | ||||
| 
 | ||||
|                     const length = parseInt(string.slice(index).match(/out_time_ms=(\d+)/)[1]); | ||||
| 
 | ||||
|                     for (const i in videoQueue) { | ||||
|                         if (videoQueue[i].videoId !== videoId) continue; | ||||
| 
 | ||||
|                         videoQueue[i].progress = length; | ||||
|                     } | ||||
|                 }); | ||||
|         } | ||||
|     })(); | ||||
| 
 | ||||
|     app.listen(PORT, () => { | ||||
|         console.log("app at http://localhost:8000/"); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| main(); | ||||
| 
 | ||||
| app.listen(8000, () => { | ||||
|     console.log("app at http://localhost:8000/"); | ||||
| }) | ||||
|  | ||||
| @ -1,15 +0,0 @@ | ||||
| CREATE TABLE users ( | ||||
|     id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, | ||||
|     username TEXT NOT NULL, | ||||
|     password TEXT NOT NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE videos ( | ||||
|     id TEXT PRIMARY KEY NOT NULL, | ||||
|     user_id INTEGER, | ||||
|     title TEXT NOT NULL, | ||||
|     description TEXT NOT NULL, | ||||
|     created_at TEXT NOT NULL, | ||||
|     FOREIGN KEY(user_id) REFERENCES users(id) | ||||
| ); | ||||
| 
 | ||||
							
								
								
									
										998
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										998
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -16,7 +16,6 @@ | ||||
|     "cors": "^2.8.5", | ||||
|     "express": "^4.18.2", | ||||
|     "express-fileupload": "^1.4.3", | ||||
|     "js-levenshtein": "^1.1.6", | ||||
|     "sqlite3": "^5.1.7" | ||||
|     "js-levenshtein": "^1.1.6" | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -30,15 +30,16 @@ function displayHeader() { | ||||
| 
 | ||||
|     document.querySelector("h1").outerHTML = ` | ||||
|         <header> | ||||
|             <h1 id="app-name">MaoTube</h1> | ||||
|             <a href="/"> | ||||
|                 <h1 id="app-name">MaoTube</h1> | ||||
|             </a> | ||||
|             <nav> | ||||
|                 ${links} | ||||
|                 - | ||||
|                 <form id="search-form" method="GET" target="_self" action="/search"> | ||||
|                     <input type="text" id="search" name="query" placeholder="Search" accesskey="s"> | ||||
|                     <input type="submit" value="Search"> | ||||
|                 </form> | ||||
|             </nav> | ||||
|             <form method="GET" target="_self" action="/search"> | ||||
|                 <input type="text" id="search" name="query" placeholder="Search"> | ||||
|                 <input type="submit" value="Search"> | ||||
|             </form> | ||||
|         </header> | ||||
|     ` | ||||
| } | ||||
|  | ||||
| @ -1,8 +0,0 @@ | ||||
| function error(message) { | ||||
|     const errorContainer = document.getElementById("mao-error"); | ||||
|     const errorElement = document.getElementById("mao-error-message"); | ||||
| 
 | ||||
|     errorElement.innerText = message || "unknown error"; | ||||
|     errorContainer.classList.remove("hidden"); | ||||
| } | ||||
| 
 | ||||
| @ -9,7 +9,7 @@ | ||||
|         <h1>MaoTube</h1> | ||||
|         <div> | ||||
|             <label for="username"><p>Username</p></label> | ||||
|             <input type="text" name="username" id="username" autofocus> | ||||
|             <input type="text" name="username" id="username"> | ||||
|             <label for="password"><p>Password</p></label> | ||||
|             <input type="password" name="password" id="password"> | ||||
|             <br> | ||||
|  | ||||
| @ -1,31 +0,0 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|     <head> | ||||
|         <meta charset="utf-8"> | ||||
|         <title>MaoTube</title> | ||||
|         <link rel="stylesheet" href="/style.css"> | ||||
|         <script defer src="/helpers.js"></script> | ||||
|         <script defer src="script.js"></script> | ||||
|         <script defer src="/header.js"></script> | ||||
|     </head> | ||||
|     <body> | ||||
|         <h1>MaoTube</h1> | ||||
|         <div> | ||||
|             <h1>Uploading videos</h1> | ||||
|         </div> | ||||
|         <br> | ||||
|         <div id="result"> | ||||
|             <noscript> | ||||
|                 <div class="mao-error"> | ||||
|                     <p>javascript not enabled</p> | ||||
|                     <p>bottom text</p> | ||||
|                 </div> | ||||
|             </noscript> | ||||
|             <div id="mao-error" class="mao-error hidden"> | ||||
|                 <p id="mao-error-message"></p> | ||||
|                 <p>bottom text</p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </body> | ||||
| </html> | ||||
| 
 | ||||
| @ -1,57 +0,0 @@ | ||||
| function readableMicroseconds(us) { | ||||
|     const min = parseInt(us / 60_000_000); | ||||
|     const sec = parseInt((us % 60_000_000) / 1_000_000); | ||||
| 
 | ||||
|     if (!min) { | ||||
|         return `${sec}s`; | ||||
|     } | ||||
| 
 | ||||
|     return `${min}m ${sec}s`; | ||||
| } | ||||
| 
 | ||||
| async function main() { | ||||
|     const response = await fetch(`/api/video-queue`); | ||||
|     if (!response.ok) { | ||||
|         error("something went wrong"); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     const json = await response.json(); | ||||
|     if (!json.ok) { | ||||
|         error(json.error); | ||||
|     } | ||||
| 
 | ||||
|     const queue = json.queue; | ||||
| 
 | ||||
|     document.getElementById("result").innerHTML = | ||||
|         `<p>Currently processing ${queue.length} video(s)</p>` | ||||
|         + `<ul id="video-list">` | ||||
|         + queue | ||||
|             .map(vid => { | ||||
|                 const percentage = parseInt(vid.progress / vid.duration * 100); | ||||
|                 const uploadedTime = readableMicroseconds(vid.progress); | ||||
|                 const totalTime = readableMicroseconds(vid.duration); | ||||
| 
 | ||||
|                 return ` | ||||
|                     <li> | ||||
|                         <div class="video-item"> | ||||
|                             <div class="video-image"> | ||||
|                                 <img class="shadow" src="/videos/${vid.videoId}.png" alt=""> | ||||
|                                 <img class="non-shadow" src="/videos/${vid.videoId}.png" alt=""> | ||||
|                             </div> | ||||
|                             <span class="video-info"> | ||||
|                                 <b>${vid.title}</b> | ||||
|                                 <br> | ||||
|                                 <p>Uploaded ${uploadedTime} of ${totalTime} (${percentage}%)</p> | ||||
|                                 <progress max="${vid.duration}" value="${vid.progress}">${percentage}%</progress> | ||||
|                             </span> | ||||
|                         </div> | ||||
|                     </li> | ||||
|                 `;
 | ||||
|             }) | ||||
|             .join("") | ||||
|         + "</ul>"; | ||||
| } | ||||
| 
 | ||||
| main(); | ||||
| 
 | ||||
| @ -9,7 +9,7 @@ | ||||
|         <h1>MaoTube</h1> | ||||
|         <div> | ||||
|             <label for="username"><p>Username</p></label> | ||||
|             <input type="text" name="username" id="username" autofocus> | ||||
|             <input type="text" name="username" id="username"> | ||||
|             <label for="password"><p>Password</p></label> | ||||
|             <input type="password" name="password" id="password"> | ||||
|             <br> | ||||
|  | ||||
| @ -17,28 +17,12 @@ function displayResponse(response) { | ||||
|         error("search returned no results"); | ||||
|         return; | ||||
|     } | ||||
|     console.log(videos); | ||||
|     const resultElement = document.getElementById("result"); | ||||
|     resultElement.innerHTML = `<p>Showing ${videos.length}/${total} results.</p>` | ||||
|         + "<ul id='video-list'>" | ||||
|         + videos | ||||
|             .map(v => { | ||||
|                 console.log(v); | ||||
|                 return ` | ||||
| 					<li> | ||||
| 						<div class="video-item"> | ||||
| 							<div class="video-image"> | ||||
| 								<img class="shadow" src="/videos/${v.id}.png" alt=""> | ||||
| 								<img class="non-shadow" src="/videos/${v.id}.png" alt=""> | ||||
| 							</div> | ||||
| 							<span class="video-info"> | ||||
| 								<a href="/watch?id=${v.id}">${v.title}</a> | ||||
| 								<br> | ||||
| 								by ${v.author} | ||||
| 							</span> | ||||
| 						</div> | ||||
| 					</li> | ||||
| 				`;
 | ||||
|                 return `<li><p><a href="/watch?id=${v.id}">${v.title}</a> - ${v.author}</p></li>` | ||||
|             }) | ||||
|             .join("")  | ||||
|         + "</ul>"; | ||||
|  | ||||
							
								
								
									
										124
									
								
								public/style.css
									
									
									
									
									
								
							
							
						
						
									
										124
									
								
								public/style.css
									
									
									
									
									
								
							| @ -1,49 +1,26 @@ | ||||
| :root { | ||||
|     color-scheme: light dark; | ||||
| 
 | ||||
|     --red: #c51e0e; | ||||
| } | ||||
| 
 | ||||
| @media (prefers-color-scheme: dark) { | ||||
|     :root { | ||||
|         --red: #F4511E; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| *, *::before, *::after { | ||||
|     box-sizing: border-box; | ||||
|     appearance: none; | ||||
| } | ||||
| 
 | ||||
| *:focus { | ||||
|     outline: 2px solid var(--red); | ||||
| :root { | ||||
|     color-scheme: light dark; | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     margin: 0 auto; | ||||
|     padding: 0; | ||||
|     text-align: center; | ||||
|     font-family: system-ui, sans-serif; | ||||
|     background-color: #dff3f1; | ||||
|     color: #211; | ||||
| } | ||||
| 
 | ||||
| a { | ||||
|     color: var(--red); | ||||
| } | ||||
| 
 | ||||
| textarea, input { | ||||
|     font-family: sans-serif; | ||||
|     font-size: 0.8em; | ||||
| } | ||||
| 
 | ||||
| nav { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
| 
 | ||||
| input::file-selector-button { | ||||
|     appearance: none; | ||||
| } | ||||
| 
 | ||||
| #search-form { | ||||
|     display: inline-block; | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   body { | ||||
|     background: #211; | ||||
|     color: #dff3f1; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .mao-error { | ||||
| @ -61,7 +38,7 @@ input::file-selector-button { | ||||
| } | ||||
| 
 | ||||
| .mao-error p { | ||||
|   color: white; | ||||
|   color: #fff; | ||||
|   text-transform: uppercase; | ||||
|   font-weight: bold; | ||||
|   font-family: "Impact", "Bebas", "League Gothic", "Oswald", "Coluna", "Ubuntu Condensed", system-ui, sans-serif; | ||||
| @ -82,75 +59,50 @@ input::file-selector-button { | ||||
|     margin-top: auto; | ||||
| } | ||||
| 
 | ||||
| #video-list { | ||||
| ul#video-list { | ||||
|     padding: 0; | ||||
|     list-style: none; | ||||
|     width: 100%; | ||||
|     max-width: 800px; | ||||
|     margin: auto; | ||||
| } | ||||
| 
 | ||||
| #video-player { | ||||
|     height: 60vh; | ||||
|     width: 100%; | ||||
|     background-color: black; | ||||
|     max-height: 80vh; | ||||
| } | ||||
| 
 | ||||
| #video-result { | ||||
|     width: 106vh; | ||||
|     display: none; | ||||
|     text-align: left; | ||||
|     margin: auto; | ||||
| } | ||||
| 
 | ||||
| .video-item { | ||||
|     padding: 0.5rem; | ||||
| header { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: flex-start; | ||||
|     gap: 2rem; | ||||
|     text-align: left; | ||||
|     background-color: rgba(255, 255, 255, 0.3); | ||||
|     border: 1px solid black; | ||||
|     align-items: center; | ||||
|     gap: 30px; | ||||
|     background-image: linear-gradient(to bottom, #c51e0e, #FF5722); | ||||
|     padding: 20px; | ||||
|     box-shadow: 0 15px 15px rgba(0, 0, 0, 0.2); | ||||
|     color: #fcf4c8; | ||||
| } | ||||
| 
 | ||||
| @media (prefers-color-scheme: dark) { | ||||
|     .video-item { | ||||
|         background-color: rgba(0, 0, 0, 0.3); | ||||
|     } | ||||
| #app-name { | ||||
|     padding-right: 20px; | ||||
|     margin: 0; | ||||
| } | ||||
| 
 | ||||
| .video-item .video-image { | ||||
|     position: relative; | ||||
|     width: 200px; | ||||
|     height: 113px; | ||||
|     overflow: hidden; | ||||
|     background-color: black; | ||||
| a { | ||||
|     color: #fcf4c8; | ||||
|     text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| .video-item .video-image img { | ||||
|     position: absolute; | ||||
|     inset: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
| #search { | ||||
|     border: none; | ||||
|     outline: none; | ||||
|     padding: 10px; | ||||
|     background-color: rgba(0, 0, 0, 0.2); | ||||
|     transition-duration: 100ms; | ||||
|     color: white; | ||||
| } | ||||
| 
 | ||||
| .video-item .shadow { | ||||
|     object-fit: cover; | ||||
|     filter: blur(0.25rem) brightness(50%); | ||||
| #search:hover { | ||||
|     background-color: rgba(0, 0, 0, 0.3); | ||||
| } | ||||
| 
 | ||||
| .video-item .non-shadow { | ||||
|     object-fit: contain; | ||||
| } | ||||
| 
 | ||||
| .video-item .video-info { | ||||
|     padding-block: 0.5em; | ||||
| } | ||||
| 
 | ||||
| .video-item a { | ||||
|     font-size: 1.4em; | ||||
|     font-weight: bold; | ||||
| #search:focus { | ||||
|     background-color: white; | ||||
|     color: black; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -4,36 +4,19 @@ | ||||
|         <meta charset="utf-8"> | ||||
|         <title>MaoTube</title> | ||||
|         <link rel="stylesheet" href="/style.css"> | ||||
|         <script defer src="/header.js"></script> | ||||
|         <script defer src="/helpers.js"></script> | ||||
|         <script defer src="script.js"></script> | ||||
|     </head> | ||||
|     <body> | ||||
|         <h1>MaoTube</h1> | ||||
|         <form id="upload-form" method="POST" action="/api/upload-video" enctype="multipart/form-data"> | ||||
|             <label for="title"><p>Title</p></label> | ||||
|             <input type="text" name="title" id="title" required autofocus> | ||||
|             <label for="description"><p>Description</p></label> | ||||
|             <textarea name="description" id="description"></textarea> | ||||
|             <label for="video"><p>Video</p></label> | ||||
|             <input type="file" name="video" id="video" required> | ||||
|             <br><br> | ||||
|         <form action="/api/upload_video" method="POST" enctype="multipart/form-data"> | ||||
|             <label for="username"><p>Title</p></label> | ||||
|             <input type="text" name="title"> | ||||
|             <label for="password"><p>Video</p></label> | ||||
|             <input type="file" name="video"> | ||||
|             <br> | ||||
|             <br> | ||||
|             <input type="submit" id="submit" value="Upload"> | ||||
|             <br><br> | ||||
|             <a href="/queue">View upload queue</a> | ||||
|         </form> | ||||
| 
 | ||||
|         <noscript> | ||||
|             <div class="mao-error"> | ||||
|                 <p>javascript not enabled</p> | ||||
|                 <p>bottom text</p> | ||||
|             </div> | ||||
|         </noscript> | ||||
| 
 | ||||
|         <div id="mao-error" class="mao-error hidden"> | ||||
|             <p id="mao-error-message"></p> | ||||
|             <p>bottom text</p> | ||||
|         </div> | ||||
|         <script src="/header.js"></script> | ||||
|     </body> | ||||
| </html> | ||||
| 
 | ||||
|  | ||||
| @ -1,22 +0,0 @@ | ||||
| document.getElementById("upload-form").addEventListener("submit", event => { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|     const data = new FormData(event.target); | ||||
| 
 | ||||
|     fetch("/api/upload-video", { | ||||
|         method: "POST", | ||||
|         body: data, | ||||
|     }) | ||||
|     .then(response => response.json()) | ||||
|     .then(response => { | ||||
|         if (!response.ok) { | ||||
|             error(response.error); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         location.href = "/queue"; | ||||
|     }) | ||||
|     .catch(err => { | ||||
|         error("failed to upload video"); | ||||
|     }); | ||||
| }); | ||||
| @ -4,35 +4,29 @@ | ||||
|         <meta charset="utf-8"> | ||||
|         <title>MaoTube</title> | ||||
|         <link rel="stylesheet" href="/style.css"> | ||||
|         <script defer src="script.js"></script> | ||||
|         <script defer src="/header.js"></script> | ||||
|     </head> | ||||
|     <body> | ||||
|         <h1>MaoTube</h1> | ||||
|         <form method="GET" target="_self" action="/search"> | ||||
|             <label for="query"><p>Search</p></label> | ||||
|             <input type="text" id="query" name="query" placeholder="..."> | ||||
|             <input type="submit" value="Search"> | ||||
|         </form> | ||||
|         <br> | ||||
|         <div id="result"> | ||||
|             <div id="video-result"> | ||||
|                 <video id="video-player"></video> | ||||
|                 <h1 id="video-title"></h1> | ||||
|                 <p id="video-author"></p> | ||||
|                 <br> | ||||
|                 <hr> | ||||
|                 <br> | ||||
|                 <p id="video-description"></p> | ||||
|             </div> | ||||
| 
 | ||||
|             <noscript> | ||||
|                 <div class="mao-error"> | ||||
|                     <p>javascript not enabled</p> | ||||
|                     <p>bottom text</p> | ||||
|                 </div> | ||||
|             </noscript> | ||||
| 
 | ||||
|             <div id="mao-error" class="mao-error hidden"> | ||||
|                 <p id="mao-error-message"></p> | ||||
|                 <p>bottom text</p> | ||||
|             </div> | ||||
|         </div> | ||||
|         <script src="script.js"></script> | ||||
|         <script src="/header.js"></script> | ||||
|     </body> | ||||
| </html> | ||||
| 
 | ||||
|  | ||||
| @ -1,14 +1,13 @@ | ||||
| 
 | ||||
| function error(message) { | ||||
|     const errorContainer = document.getElementById("mao-error"); | ||||
|     const errorElement = document.getElementById("mao-error-message"); | ||||
| 
 | ||||
|     document.getElementById("video-result").style.display = "none"; | ||||
| 
 | ||||
|     errorElement.innerText = message || "unknown error"; | ||||
|     errorElement.innerText = message; | ||||
|     errorContainer.classList.remove("hidden"); | ||||
| } | ||||
| 
 | ||||
| async function main() { | ||||
| function main() { | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     const id = params.get("id"); | ||||
|     if (!id) { | ||||
| @ -16,36 +15,17 @@ async function main() { | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     const info = await fetch(`/api/video-info?id=${id}`); | ||||
|     if (!info.ok) { | ||||
|         error("error fetching video info"); | ||||
|         return; | ||||
|     const result = document.getElementById("result"); | ||||
| 
 | ||||
|     const video = document.createElement("video"); | ||||
|     video.controls = true; | ||||
|     video.id = "video-player"; | ||||
|     video.src = `/videos/${id}.mp4`; | ||||
|     result.appendChild(video); | ||||
|     video.onerror = () => { | ||||
|         video.remove(); | ||||
|         error("invalid id parameter"); | ||||
|     } | ||||
| 
 | ||||
|     const json = await info.json(); | ||||
|     if (!json.ok) { | ||||
|         error(json.error); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     const video = json.video; | ||||
| 
 | ||||
|     const player = document.getElementById("video-player"); | ||||
|     player.controls = true; | ||||
|     player.src = video.path; | ||||
|     player.onerror = () => { | ||||
|         error("unable to play video"); | ||||
|     } | ||||
|     player.onload = () => { | ||||
|         player.style.height = player.clientWidth / 16 * 9 + "px"; | ||||
|     } | ||||
| 
 | ||||
|     document.getElementById("video-title").innerText = video.title; | ||||
|     document.getElementById("video-author").innerText = "by " + video.author + " - published " + new Date(video.created_at).toLocaleDateString(); | ||||
|     document.getElementById("video-description").innerText = video.description; | ||||
| 
 | ||||
|     document.getElementById("video-result").style.display = "block"; | ||||
| } | ||||
| 
 | ||||
| main(); | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user