Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack. Made-with: Cursor
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
import type { Express, Request, Response } from "express";
|
|
import type { RowDataPacket, ResultSetHeader } from "mysql2";
|
|
import type mysql from "mysql2/promise";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { randomUUID } from "crypto";
|
|
import multer from "multer";
|
|
|
|
const TABLE = "youwei_course_video";
|
|
const UPLOAD_SUBDIR = "videos";
|
|
|
|
function uploadsRoot(): string {
|
|
return path.join(process.cwd(), "uploads");
|
|
}
|
|
|
|
function videosDir(): string {
|
|
return path.join(uploadsRoot(), UPLOAD_SUBDIR);
|
|
}
|
|
|
|
export async function ensureCourseVideoTable(pool: mysql.Pool): Promise<void> {
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
|
title VARCHAR(512) NOT NULL,
|
|
course_key VARCHAR(32) NOT NULL,
|
|
course_label_zh VARCHAR(128) NOT NULL,
|
|
course_label_en VARCHAR(128) NOT NULL,
|
|
module_name VARCHAR(256) NOT NULL DEFAULT '',
|
|
file_relpath VARCHAR(1024) NOT NULL,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
|
views INT NOT NULL DEFAULT 0,
|
|
duration VARCHAR(32) NOT NULL DEFAULT '00:00',
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
`);
|
|
}
|
|
|
|
function mapRow(r: RowDataPacket) {
|
|
return {
|
|
id: r.id,
|
|
title: r.title,
|
|
courseKey: r.course_key,
|
|
courseLabelZh: r.course_label_zh,
|
|
courseLabelEn: r.course_label_en,
|
|
moduleName: r.module_name,
|
|
videoUrl: r.file_relpath,
|
|
status: r.status,
|
|
views: r.views,
|
|
duration: r.duration,
|
|
uploadDate: r.created_at instanceof Date ? r.created_at.toISOString().split("T")[0] : String(r.created_at).split(" ")[0],
|
|
};
|
|
}
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => {
|
|
const dir = videosDir();
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
cb(null, dir);
|
|
},
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname) || ".mp4";
|
|
cb(null, `${randomUUID()}${ext}`);
|
|
},
|
|
});
|
|
|
|
const uploadMw = multer({
|
|
storage,
|
|
limits: { fileSize: 500 * 1024 * 1024 },
|
|
fileFilter: (_req, file, cb) => {
|
|
if (file.mimetype.startsWith("video/")) {
|
|
cb(null, true);
|
|
return;
|
|
}
|
|
cb(new Error("Only video files are allowed"));
|
|
},
|
|
});
|
|
|
|
export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Pool): void {
|
|
app.get("/api/course-videos", async (req: Request, res: Response) => {
|
|
try {
|
|
const pool = getPool();
|
|
await ensureCourseVideoTable(pool);
|
|
const courseKey = typeof req.query.courseKey === "string" ? req.query.courseKey.trim() : "";
|
|
const scope = typeof req.query.scope === "string" ? req.query.scope : "";
|
|
const isAdmin = scope === "admin";
|
|
|
|
let sql = `SELECT * FROM ${TABLE}`;
|
|
const params: string[] = [];
|
|
const where: string[] = [];
|
|
|
|
if (!isAdmin) {
|
|
where.push("status = ?");
|
|
params.push("published");
|
|
}
|
|
if (courseKey) {
|
|
where.push("course_key = ?");
|
|
params.push(courseKey);
|
|
}
|
|
if (where.length) sql += ` WHERE ${where.join(" AND ")}`;
|
|
sql += " ORDER BY created_at DESC";
|
|
|
|
const [rows] = await pool.query<RowDataPacket[]>(sql, params);
|
|
res.json({ ok: true, videos: rows.map(mapRow) });
|
|
} catch (err) {
|
|
console.error("[course-videos GET]", err);
|
|
res.status(503).json({ ok: false, error: "Could not load videos" });
|
|
}
|
|
});
|
|
|
|
app.post(
|
|
"/api/course-videos",
|
|
(req, res, next) => {
|
|
uploadMw.single("file")(req, res, (e) => {
|
|
if (e) {
|
|
res.status(400).json({ ok: false, error: e instanceof Error ? e.message : "Upload failed" });
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
},
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
const pool = getPool();
|
|
await ensureCourseVideoTable(pool);
|
|
const file = req.file;
|
|
if (!file) {
|
|
res.status(400).json({ ok: false, error: "Video file is required" });
|
|
return;
|
|
}
|
|
|
|
const title = String(req.body.title || "").trim();
|
|
const courseKey = String(req.body.courseKey || "").trim();
|
|
const courseLabelZh = String(req.body.courseLabelZh || "").trim();
|
|
const courseLabelEn = String(req.body.courseLabelEn || "").trim();
|
|
const moduleName = String(req.body.moduleName || "").trim();
|
|
const statusRaw = String(req.body.status || "published").trim().toLowerCase();
|
|
const status = statusRaw === "draft" ? "draft" : "published";
|
|
|
|
if (!title || !courseKey) {
|
|
fs.unlink(file.path, () => {});
|
|
res.status(400).json({ ok: false, error: "Missing title or courseKey" });
|
|
return;
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const rel = `/uploads/${UPLOAD_SUBDIR}/${file.filename}`;
|
|
|
|
await pool.execute(
|
|
`INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '00:00')`,
|
|
[id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status],
|
|
);
|
|
|
|
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
|
|
res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null });
|
|
} catch (err) {
|
|
console.error("[course-videos POST]", err);
|
|
res.status(503).json({ ok: false, error: "Could not save video" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.patch("/api/course-videos/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
const pool = getPool();
|
|
await ensureCourseVideoTable(pool);
|
|
const id = req.params.id;
|
|
const status = typeof req.body?.status === "string" ? req.body.status.trim().toLowerCase() : "";
|
|
if (status !== "published" && status !== "draft" && status !== "processing") {
|
|
res.status(400).json({ ok: false, error: "Invalid status" });
|
|
return;
|
|
}
|
|
const [result] = await pool.execute<ResultSetHeader>(
|
|
`UPDATE ${TABLE} SET status = ? WHERE id = ?`,
|
|
[status, id],
|
|
);
|
|
if (result.affectedRows === 0) {
|
|
res.status(404).json({ ok: false, error: "Not found" });
|
|
return;
|
|
}
|
|
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
|
|
res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null });
|
|
} catch (err) {
|
|
console.error("[course-videos PATCH]", err);
|
|
res.status(503).json({ ok: false, error: "Could not update video" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/course-videos/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
const pool = getPool();
|
|
await ensureCourseVideoTable(pool);
|
|
const id = req.params.id;
|
|
const [rows] = await pool.query<RowDataPacket[]>(`SELECT file_relpath FROM ${TABLE} WHERE id = ?`, [id]);
|
|
const row = rows[0];
|
|
if (!row) {
|
|
res.status(404).json({ ok: false, error: "Not found" });
|
|
return;
|
|
}
|
|
const rel = String(row.file_relpath || "");
|
|
await pool.execute(`DELETE FROM ${TABLE} WHERE id = ?`, [id]);
|
|
if (rel.startsWith("/uploads/")) {
|
|
const abs = path.join(process.cwd(), rel.replace(/^\//, ""));
|
|
fs.unlink(abs, () => {});
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error("[course-videos DELETE]", err);
|
|
res.status(503).json({ ok: false, error: "Could not delete video" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/course-videos/:id/view", async (req: Request, res: Response) => {
|
|
try {
|
|
const pool = getPool();
|
|
await ensureCourseVideoTable(pool);
|
|
const id = req.params.id;
|
|
await pool.execute(`UPDATE ${TABLE} SET views = views + 1 WHERE id = ?`, [id]);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error("[course-videos view]", err);
|
|
res.status(503).json({ ok: false });
|
|
}
|
|
});
|
|
}
|