initial commit
This commit is contained in:
commit
8d8cd9aa2b
1
icon/download.svg
Normal file
1
icon/download.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#fff"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" /></svg>
|
After Width: | Height: | Size: 133 B |
27
index.html
Normal file
27
index.html
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>VideoBase</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<script src="js/config.js" defer></script>
|
||||
<script src="js/api.js" defer></script>
|
||||
<script src="js/components.js" defer></script>
|
||||
<script src="js/index.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="style/base.css">
|
||||
<link rel="stylesheet" href="style/components.css">
|
||||
<link rel="stylesheet" href="style/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>Unfortunately, this page requires JavaScript to work correctly</noscript>
|
||||
<div id="loading">Loading...</div>
|
||||
|
||||
<h1>Library</h1>
|
||||
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
112
js/api.js
Normal file
112
js/api.js
Normal file
@ -0,0 +1,112 @@
|
||||
class API {
|
||||
|
||||
/**
|
||||
* @type {!string}
|
||||
*/
|
||||
apiURL;
|
||||
|
||||
/**
|
||||
* @param {!string} apiURL
|
||||
*/
|
||||
constructor(apiURL) {
|
||||
this.apiURL = apiURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"GET" | "POST" | "PUT"} method
|
||||
* @param {!string} path
|
||||
* @param {?Object.<string, string>} query
|
||||
* @param {?any} body
|
||||
* @returns {?any}
|
||||
* @throws {APIError}
|
||||
*/
|
||||
async call(method, path, query, body) {
|
||||
let params = new URLSearchParams();
|
||||
if (query != null) {
|
||||
for (let key in query) {
|
||||
let value = query[key];
|
||||
if (value == null) continue;
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
let response = await fetch(this.apiURL + path + (params.size > 0 ? "?" + params : ""),
|
||||
{
|
||||
method,
|
||||
headers: (body != null ? { "Content-Type": "application/json" } : undefined),
|
||||
body
|
||||
}
|
||||
);
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (e) {
|
||||
throw "Failed to decode response";
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw json?.error || "Request failed: " + response.status + " " + response.statusText;
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?("series" | "author")} groupBy
|
||||
* @returns {Library}
|
||||
* @throws {APIError}
|
||||
*/
|
||||
async getLibrary(groupBy) {
|
||||
return await this.call("GET", "/library", { 'groupBy': groupBy }, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!string} id
|
||||
* @returns {Video}
|
||||
* @throws {APIError}
|
||||
*/
|
||||
async getVideo(id) {
|
||||
return await this.call("GET", "/library/video/" + encodeURIComponent(id), null, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class APIError {
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
errorMessage;
|
||||
|
||||
/**
|
||||
* @param {!string} errorMessage
|
||||
*/
|
||||
constructor(errorMessage) {
|
||||
this.errorMessage = errorMessage
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Library {
|
||||
/**
|
||||
* @type {Object<string, Video[]>}
|
||||
*/
|
||||
videos;
|
||||
}
|
||||
|
||||
class Video {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
id;
|
||||
|
||||
/**
|
||||
* @type {VideoMetadata}
|
||||
*/
|
||||
metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{series:string, title: string, author: string, index: number, [key: string]: any}} VideoMetadata
|
||||
*/
|
85
js/components.js
Normal file
85
js/components.js
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @param {string} type
|
||||
* @param {?{classNames: ?string[], content: ?string, attributes: ?Object.<string, string>, onClick: ?(() => void)}} props
|
||||
* @param {?HTMLElement[]} children
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function element(type, props, children) {
|
||||
let element = document.createElement(type);
|
||||
|
||||
if (props?.classNames != null && props.classNames.length > 0) {
|
||||
element.classList.add(props.classNames);
|
||||
}
|
||||
|
||||
if (props?.content != null) {
|
||||
element.innerText = props.content;
|
||||
}
|
||||
|
||||
if (props?.attributes != null) {
|
||||
for (let attribute in props.attributes) {
|
||||
element.setAttribute(attribute, props.attributes[attribute]);
|
||||
}
|
||||
}
|
||||
|
||||
if (props?.onClick != null) {
|
||||
element.onclick = props.onClick;
|
||||
}
|
||||
|
||||
if (children != null) {
|
||||
for (let child of children) {
|
||||
element.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
const components = {
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {Video[]} videos
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
series(name, videos) {
|
||||
return element("div", { classNames: ["series"] }, [
|
||||
element("a", { content: name, attributes: { "href": "/series.html?name=" + encodeURIComponent(name) } }),
|
||||
element("div", { content: [...new Set(videos.map(v => v.metadata.author))].join(", "), classNames: ["series-authors"] }),
|
||||
element("div", { classNames: ["series-videos"] }, videos.map(v => components.video(v)))
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Video} video
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
video(video) {
|
||||
return element("a", { classNames: ["video"], attributes: { "href": "/watch.html?id=" + encodeURIComponent(video.id), "target": "_blank" } }, [
|
||||
element("img", { attributes: { "src": API_URL + "/library/video/" + encodeURIComponent(video.id) + "/thumbnail" } }),
|
||||
element("span", { content: video.metadata.title }),
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Video} video
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
player(video) {
|
||||
const streamURL = API_URL + "/library/video/" + encodeURIComponent(video.id) + "/stream";
|
||||
|
||||
const downloadVideo = () => {
|
||||
window.location.href = streamURL;
|
||||
};
|
||||
|
||||
return element("div", { classNames: ["player"] }, [
|
||||
element("video", { attributes: { "width": "960", "height": "540", "poster": API_URL + "/library/video/" + encodeURIComponent(video.id) + "/thumbnail" } }, [
|
||||
element("source", { attributes: { "src": streamURL } })
|
||||
]),
|
||||
element("button", { onClick: downloadVideo }, [
|
||||
element("img", { attributes: { "src": "/icon/download.svg", "width": "24", "height": "24" } }),
|
||||
element("span", { content: "Download" }),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
1
js/config.js
Normal file
1
js/config.js
Normal file
@ -0,0 +1 @@
|
||||
const API_URL = "http://localhost:6969/api"
|
15
js/index.js
Normal file
15
js/index.js
Normal file
@ -0,0 +1,15 @@
|
||||
const api = new API(API_URL);
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
async function init() {
|
||||
let library = await api.getLibrary("series");
|
||||
|
||||
for (let group in library.videos) {
|
||||
root.appendChild(components.series(group, library.videos[group]));
|
||||
}
|
||||
|
||||
document.getElementById("loading").style.display = 'none';
|
||||
}
|
||||
|
||||
init();
|
14
js/watch.js
Normal file
14
js/watch.js
Normal file
@ -0,0 +1,14 @@
|
||||
const api = new API(API_URL);
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
async function init() {
|
||||
let query = new URLSearchParams(document.location.search);
|
||||
|
||||
let video = await api.getVideo(query.get("id"));
|
||||
root.appendChild(components.player(video));
|
||||
|
||||
document.getElementById("loading").style.display = 'none';
|
||||
}
|
||||
|
||||
init();
|
88
style/base.css
Normal file
88
style/base.css
Normal file
@ -0,0 +1,88 @@
|
||||
html {
|
||||
--background: #060f1a;
|
||||
--foreground: #0d294a;
|
||||
--accent: #3584e4;
|
||||
--text: #fff;
|
||||
--text-alternative: #bbb;
|
||||
--link: var(--accent);
|
||||
|
||||
--info: var(--accent);
|
||||
--success: green;
|
||||
--warning: darkorange;
|
||||
--error: orangered;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: system-ui;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:has(img) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
background: #2c3444;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
button:focus,
|
||||
a:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
a {
|
||||
background: unset;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#loading {
|
||||
background-color: var(--accent);
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
60
style/components.css
Normal file
60
style/components.css
Normal file
@ -0,0 +1,60 @@
|
||||
.series {
|
||||
background-color: var(--foreground);
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.series>a {
|
||||
font-size: 1.2em;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.series-authors {
|
||||
color: var(--text-alternative);
|
||||
}
|
||||
|
||||
.series-videos {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: scroll;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.video {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(gray);
|
||||
border-radius: 5px;
|
||||
max-width: min-content;
|
||||
}
|
||||
|
||||
.video>img {
|
||||
width: 300px;
|
||||
/* height: 168.75px; */
|
||||
aspect-ratio: 16/9;
|
||||
background-color: var(--background);
|
||||
height: auto;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.video>span {
|
||||
color: var(--text-alternative);
|
||||
}
|
||||
|
||||
.player {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 5px;
|
||||
|
||||
background-color: var(--foreground);
|
||||
max-width: min-content;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
1
style/index.css
Normal file
1
style/index.css
Normal file
@ -0,0 +1 @@
|
||||
|
0
style/watch.css
Normal file
0
style/watch.css
Normal file
23
watch.html
Normal file
23
watch.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>VideoBase</title>
|
||||
<script src="js/config.js" defer></script>
|
||||
<script src="js/api.js" defer></script>
|
||||
<script src="js/components.js" defer></script>
|
||||
<script src="js/watch.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="style/base.css">
|
||||
<link rel="stylesheet" href="style/components.css">
|
||||
<link rel="stylesheet" href="style/watch.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>Unfortunately, this page requires JavaScript to work correctly</noscript>
|
||||
<div id="loading">Loading...</div>
|
||||
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user