docker-composeの設定追加、TypeScriptの型定義更新、管理画面用のヘッダー追加、エラーページの修正、CSSスタイルの調整

This commit is contained in:
ntki72 2024-12-27 21:00:54 +09:00
parent 29cebe239f
commit 62d1e72ae9
34 changed files with 950 additions and 365 deletions

View File

@ -0,0 +1,5 @@
services:
app:
volumes:
- ./public/css:/app/public/css
- ./views:/app/views

View File

@ -16,7 +16,6 @@ services:
TZ: Asia/Tokyo TZ: Asia/Tokyo
volumes: volumes:
- ./public/thumbnails:/app/public/thumbnails - ./public/thumbnails:/app/public/thumbnails
- ./public/css:/app/public/css
db: db:
image: mysql:8.0 image: mysql:8.0
volumes: volumes:

76
package-lock.json generated
View File

@ -14,6 +14,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.0", "express-basic-auth": "^1.2.0",
"express-session": "^1.18.1",
"method-override": "^3.0.0", "method-override": "^3.0.0",
"mysql2": "^3.3.3", "mysql2": "^3.3.3",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
@ -22,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/express-session": "^1.18.1",
"@types/method-override": "^3.0.0", "@types/method-override": "^3.0.0",
"@types/node": "^20.4.2", "@types/node": "^20.4.2",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
@ -117,6 +119,16 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/express-session": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.1.tgz",
"integrity": "sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@ -977,6 +989,40 @@
"basic-auth": "^2.0.1" "basic-auth": "^2.0.1"
} }
}, },
"node_modules/express-session": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/ext": { "node_modules/ext": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
@ -1753,6 +1799,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -1857,6 +1912,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -2439,6 +2503,18 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/umzug": { "node_modules/umzug": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz",

View File

@ -13,6 +13,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.0", "express-basic-auth": "^1.2.0",
"express-session": "^1.18.1",
"method-override": "^3.0.0", "method-override": "^3.0.0",
"mysql2": "^3.3.3", "mysql2": "^3.3.3",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
@ -21,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/express-session": "^1.18.1",
"@types/method-override": "^3.0.0", "@types/method-override": "^3.0.0",
"@types/node": "^20.4.2", "@types/node": "^20.4.2",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",

View File

@ -4,45 +4,36 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 60px; height: 60px;
background-color: #333; background-color: #1a1a1a;
color: white; color: white;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 1000; z-index: 1000;
box-sizing: border-box; box-sizing: border-box;
} }
.header-left a { .header-left span {
color: white; color: white;
font-size: 1.5em; font-size: 1.6em;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
white-space: nowrap;
} }
.header-right { .header-right {
display: flex; display: flex;
gap: 20px; gap: 15px;
margin-right: 10px;
white-space: nowrap;
} }
.header-link { .header-link {
color: white; color: #ddd;
text-decoration: none; text-decoration: none;
font-size: 1em; font-size: 1em;
transition: color 0.2s; transition: color 0.2s;
} }
.header-link:hover { .header-link:hover {
color: #ddd; color: white;
}
@media (max-width: 768px) {
.header-right {
margin-right: 5px;
}
} }

View File

@ -0,0 +1,211 @@
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.main-content {
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 20px;
}
.add-channel {
text-align: center;
margin-bottom: 20px;
}
.add-channel .btn {
display: inline-block;
padding: 10px 20px;
background-color: #007BFF;
color: #fff;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s ease;
}
.add-channel .btn:hover {
background-color: #0056b3;
}
.admin-table {
width: 100%;
border-collapse: collapse;
margin: 0 auto;
}
.admin-table th, .admin-table td {
border: 1px solid #ddd;
padding: 10px;
text-align: center;
}
.admin-table th {
background-color: #f4f4f4;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.btn {
display: block;
padding: 5px 10px;
text-align: center;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-edit {
background-color: #FFC107;
color: #fff;
}
.btn-edit:hover {
background-color: #d39e00;
}
.btn-delete {
background-color: #DC3545;
color: #fff;
}
.btn-delete:hover {
background-color: #a71d2a;
}
.btn-run {
background-color: #28A745;
color: #fff;
}
.btn-run:hover {
background-color: #1e7e34;
}
.btn-fetch {
background-color: #17A2B8;
color: #fff;
}
.btn-fetch:hover {
background-color: #117a8b;
}
form label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.date-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.date-group label {
display: flex;
flex-direction: column;
align-items: flex-start;
}
form input {
margin-top: 5px;
padding: 5px;
width: 100%;
box-sizing: border-box;
}
.admin-form {
max-width: 500px;
margin: 0 auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
}
.admin-form .form-group {
margin-bottom: 15px;
}
.admin-form .form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.admin-form .form-group input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.admin-form .form-note {
font-size: 0.9em;
color: #666;
margin-bottom: 15px;
text-align: center;
}
.admin-form .form-actions {
display: flex;
justify-content: space-between;
}
.admin-form .btn {
display: inline-block;
padding: 10px 15px;
text-align: center;
text-decoration: none;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
}
.admin-form .btn-add {
background-color: #28A745;
color: #fff;
}
.admin-form .btn-add:hover {
background-color: #218838;
}
.admin-form .btn-update {
background-color: #007BFF;
color: #fff;
}
.admin-form .btn-update:hover {
background-color: #0056b3;
}
.admin-form .btn-back {
background-color: #6C757D;
color: #fff;
}
.admin-form .btn-back:hover {
background-color: #5A6268;
}
.header-spacer {
height: 60px;
}
.main-content {
margin-top: 20px;
}

View File

@ -0,0 +1,48 @@
.header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 60px;
background-color: #ff4b4b;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
box-sizing: border-box;
}
.header-left a {
color: white;
font-size: 1.5em;
font-weight: bold;
text-decoration: none;
white-space: nowrap;
}
.header-right {
display: flex;
gap: 20px;
margin-right: 10px;
white-space: nowrap;
}
.header-link {
color: white;
text-decoration: none;
font-size: 1em;
transition: color 0.2s;
}
.header-link:hover {
color: #ddd;
}
@media (max-width: 768px) {
.header-right {
margin-right: 5px;
}
}

View File

@ -1,13 +1,28 @@
body {
font-family: 'Noto Sans JP', Arial, sans-serif;
background-color: #f0f0f0;
color: #333;
margin: 0;
padding: 0;
}
.schedule-container { .schedule-container {
width: 80%; width: 100%;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
} }
.date-header { .date-header {
font-size: 1.5em; font-size: 1.8em;
margin: 20px 0;
font-weight: bold; font-weight: bold;
min-width: 550px; color: #222;
margin: 20px 0;
border-bottom: 2px solid #333;
padding-bottom: 10px;
} }
.banners { .banners {
@ -15,19 +30,20 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 20px; gap: 20px;
} }
.banner { .banner {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
color: black; color: black;
background-color: #f9f9f9; background-color: #ffffff;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
flex: 1 1 calc(50% - 13px); flex: 1 1 calc(50% - 13px);
max-width: calc(50% - 13px); max-width: calc(50% - 13px);
min-width: 550px; min-width: 550px; /* デフォルトはPC向け */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
@ -35,12 +51,25 @@
.banner:hover { .banner:hover {
transform: scale(1.02); transform: scale(1.02);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15); box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15);
border-color: #444;
} }
/* 1時間以内のバナーを強調表示 */
.banner.highlight-live {
background-color: #ffe5e5; /* 明るい赤系 */
border-color: #ff4d4d; /* 濃い赤 */
}
.banner.highlight-live .details {
color: #a10000; /* 赤系の文字色 */
}
/* サムネイル部分 */
.thumbnail img { .thumbnail img {
width: 150px; width: 150px;
height: auto; height: auto;
object-fit: cover; object-fit: cover;
border-right: 1px solid #ddd;
} }
.details { .details {
@ -57,7 +86,7 @@
.title { .title {
font-size: 1.2em; font-size: 1.2em;
font-weight: bold; font-weight: bold;
color: #555; color: #222;
} }
.header-spacer { .header-spacer {
@ -65,5 +94,14 @@
} }
.main-content { .main-content {
margin-top: 20px; background-color: #f9f9f9;
}
/* スマホや縦長画面の場合 */
@media (max-width: 768px) {
.banner {
flex: 1 1 100%; /* 横幅を画面いっぱいにする */
max-width: 100%;
min-width: 100%;
}
} }

View File

@ -6,7 +6,7 @@ body {
.terms-container { .terms-container {
max-width: 800px; max-width: 800px;
margin: 80px auto; /* ヘッダーの高さを考慮して調整 */ margin: 80px auto;
padding: 20px; padding: 20px;
background-color: #ffffff; background-color: #ffffff;
border-radius: 8px; border-radius: 8px;
@ -45,3 +45,25 @@ body {
text-decoration: underline; text-decoration: underline;
} }
.back-to-top {
margin-top: 30px;
text-align: center;
}
.btn-back {
display: inline-block;
padding: 10px 20px;
background-color: #444;
color: #fff;
text-decoration: none;
border-radius: 5px;
font-size: 1em;
transition: background-color 0.3s ease, transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.btn-back:hover {
background-color: #222;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}

View File

@ -1,14 +1,16 @@
import { WEB_PORT, BASIC_AUTH_USER, BASIC_AUTH_PASS } from "./config/env"; import { WEB_PORT, BASIC_AUTH_USER, BASIC_AUTH_PASS, SESSION_SECRET } from "./config/env";
import cron from "node-cron"; import cron from "node-cron";
import express from "express"; import express from "express";
import apiRoutes from "./routes/api"; import session from "express-session";
import adminRoutes from "./routes/admin"; import adminRoutes from "./routes/admin";
import userRoutes from "./routes/user"; import userRoutes from "./routes/user";
import basicAuth from "express-basic-auth"; import basicAuth from "express-basic-auth";
import { runMigrations } from "./utils/migrate"; import { runMigrations } from "./utils/migrate";
import methodOverride from "method-override"; import methodOverride from "method-override";
import { updateAllLiveSchedules } from "./services/updateLiveSchedules"; import { updateAllLiveSchedules } from "./services/updateLiveSchedules";
import scheduleRoutes from "./routes/schedule";
// セッションとベーシック認証の有効期限
const SESSION_EXPIRATION = 60 * 1 * 1000; // テスト目的で1分に設定
const app = express(); const app = express();
@ -17,22 +19,65 @@ const startServer = async () => {
// マイグレーション実行 // マイグレーション実行
await runMigrations(); await runMigrations();
// // 定期実行タスクを設定1時間ごと // 開発中なので定期実行タスクをコメントアウト
// cron.schedule("0 * * * *", async () => { // cron.schedule("0 * * * *", async () => {
// console.log("Updating live schedules..."); // console.log("Updating live schedules...");
// await updateAllLiveSchedules(); // await updateAllLiveSchedules();
// }); // });
// セッション設定
// ベーシック認証
app.use( app.use(
"/admin", session({
basicAuth({ secret: SESSION_SECRET,
users: { [BASIC_AUTH_USER]: BASIC_AUTH_PASS }, resave: false,
challenge: true, saveUninitialized: false,
rolling: false,
cookie: { maxAge: SESSION_EXPIRATION, secure: false, httpOnly: true },
}) })
); );
// キャッシュを抑止
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
next();
});
/**
*
* 1.
* 2. 401 Basic認証を要求
* 3.
*/
app.use("/hyAdmin", (req, res, next) => {
const sessionExpires = req.session.cookie.expires;
// セッションが有効期限内の場合はそのまま進む
if (req.session.isAuthenticated && sessionExpires && sessionExpires > new Date()) {
return next();
}
// セッションが切れた(または存在しない)場合、
// Basic認証を実行して認証に成功すればセッション再生成、失敗すれば401を返す
basicAuth({
users: { [BASIC_AUTH_USER]: BASIC_AUTH_PASS },
challenge: true, // 401時にWWW-Authenticateヘッダーを付加
unauthorizedResponse: "Unauthorized",
})(req, res, (authError) => {
// authError があればそのままエラーに
if (authError) {
return next(authError);
}
// 認証が通ったのでセッション再生成
req.session.isAuthenticated = true;
req.session.cookie.expires = new Date(Date.now() + SESSION_EXPIRATION);
return next();
});
});
// 管理画面ビューの設定 // 管理画面ビューの設定
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.set("views", "./views"); app.set("views", "./views");
@ -45,24 +90,22 @@ const startServer = async () => {
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// 管理画面ルート // 管理画面ルート
app.use("/admin", adminRoutes); app.use("/hyAdmin", adminRoutes);
// ユーザー向けルート // ユーザー向けルート
app.use("/", userRoutes); app.use("/", userRoutes);
// 管理画面 API
app.use("/admin/api", apiRoutes);
// 静的ファイルの提供 // 静的ファイルの提供
app.use("/thumbnails", express.static("public/thumbnails")); app.use("/thumbnails", express.static("public/thumbnails"));
app.use("/css", express.static("public/css")); app.use("/css", express.static("public/css"));
app.use("/js", express.static("public/js")); app.use("/js", express.static("public/js"));
// エラーハンドリング // 404エラーハンドリング
app.use((req, res, next) => { app.use((req, res, next) => {
res.status(404).render("errors/404"); res.status(404).render("errors/404");
}); });
// その他エラーハンドリング
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error("Unhandled error:", err); console.error("Unhandled error:", err);

View File

@ -12,6 +12,7 @@ const requiredEnvVars = [
"BASIC_AUTH_PASS", "BASIC_AUTH_PASS",
"WEB_PORT", "WEB_PORT",
"YOUTUBE_API_KEY", "YOUTUBE_API_KEY",
"SESSION_SECRET",
]; ];
requiredEnvVars.forEach((varName) => { requiredEnvVars.forEach((varName) => {
@ -28,3 +29,4 @@ export const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER as string;
export const BASIC_AUTH_PASS = process.env.BASIC_AUTH_PASS as string; export const BASIC_AUTH_PASS = process.env.BASIC_AUTH_PASS as string;
export const WEB_PORT = parseInt(process.env.WEB_PORT as string, 10); export const WEB_PORT = parseInt(process.env.WEB_PORT as string, 10);
export const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY as string; export const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY as string;
export const SESSION_SECRET = process.env.SESSION_SECRET as string;

View File

@ -1,17 +1,22 @@
import dayjs from "dayjs";
import { Router } from "express"; import { Router } from "express";
import { Channel } from "../models"; import { Channel, LiveSchedule } from "../models";
import { getChannelIdByHandle, fetchPastLiveStreams } from "../utils/youtube"; import { getChannelIdByHandle, fetchPastLiveStreams } from "../utils/youtube";
import { saveLiveSchedules } from "../services/liveScheduleService"; import { saveLiveSchedules } from "../services/liveScheduleService";
import { updateLiveSchedulesForChannel } from "../services/liveScheduleService"; import { updateLiveSchedulesForChannel } from "../services/liveScheduleService";
const router = Router(); const router = Router();
// 管理画面トップページ
router.get("/", (req, res) => {
res.render("admin/index");
});
// チャンネル一覧表示 // チャンネル一覧表示
router.get("/channels", async (req, res) => { router.get("/channel", async (req, res) => {
try { try {
const channels = await Channel.findAll(); const channels = await Channel.findAll();
res.render("admin/channels", { title: "Channels", channels }); res.render("admin/channel", { channels });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send("Failed to load channels"); res.status(500).send("Failed to load channels");
@ -19,12 +24,12 @@ router.get("/channels", async (req, res) => {
}); });
// チャンネル登録フォームの表示 // チャンネル登録フォームの表示
router.get("/channels/new", (req, res) => { router.get("/channel/new", (req, res) => {
res.render("admin/new-channel", { title: "Add New Channel" }); res.render("admin/channel-new");
}); });
// チャンネル登録処理 // チャンネル登録処理
router.post("/channels/new", async (req, res) => { router.post("/channel/new", async (req, res) => {
try { try {
const { name, channel_handle, youtube_id } = req.body; const { name, channel_handle, youtube_id } = req.body;
@ -58,7 +63,7 @@ router.post("/channels/new", async (req, res) => {
youtube_id: resolvedYoutubeId, youtube_id: resolvedYoutubeId,
}); });
res.redirect("/admin/channels"); res.redirect("/hyAdmin/channel");
} catch (error) { } catch (error) {
console.error("Failed to create channel:", error); console.error("Failed to create channel:", error);
res.status(500).send("An error occurred while creating the channel."); res.status(500).send("An error occurred while creating the channel.");
@ -67,7 +72,7 @@ router.post("/channels/new", async (req, res) => {
// チャンネル編集フォームの表示 // チャンネル編集フォームの表示
router.get("/channels/:id/edit", async (req, res) => { router.get("/channel/:id/edit", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const channel = await Channel.findByPk(id); const channel = await Channel.findByPk(id);
@ -76,7 +81,7 @@ router.get("/channels/:id/edit", async (req, res) => {
return res.status(404).send("Channel not found"); return res.status(404).send("Channel not found");
} }
res.render("admin/edit-channel", { title: "Edit Channel", channel }); res.render("admin/channel-edit", { channel });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send("Failed to load channel for editing"); res.status(500).send("Failed to load channel for editing");
@ -84,7 +89,7 @@ router.get("/channels/:id/edit", async (req, res) => {
}); });
// チャンネル更新処理 // チャンネル更新処理
router.put("/channels/:id", async (req, res) => { router.put("/channel/:id", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, youtube_id } = req.body; const { name, youtube_id } = req.body;
@ -98,7 +103,7 @@ router.put("/channels/:id", async (req, res) => {
channel.youtube_id = youtube_id; channel.youtube_id = youtube_id;
await channel.save(); await channel.save();
res.redirect("/admin/channels"); res.redirect("/hyAdmin/channel");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send("Failed to update channel"); res.status(500).send("Failed to update channel");
@ -106,7 +111,7 @@ router.put("/channels/:id", async (req, res) => {
}); });
// チャンネル削除処理 // チャンネル削除処理
router.delete("/channels/:id", async (req, res) => { router.delete("/channel/:id", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const channel = await Channel.findByPk(id); const channel = await Channel.findByPk(id);
@ -116,7 +121,7 @@ router.delete("/channels/:id", async (req, res) => {
} }
await channel.destroy(); await channel.destroy();
res.redirect("/admin/channels"); res.redirect("/hyAdmin/channel");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send("Failed to delete channel"); res.status(500).send("Failed to delete channel");
@ -124,7 +129,7 @@ router.delete("/channels/:id", async (req, res) => {
}); });
// チャンネルの配信スケジュールを更新 // チャンネルの配信スケジュールを更新
router.get("/channels/:id/run", async (req, res) => { router.get("/channel/:id/run", async (req, res) => {
try { try {
const channel = await Channel.findByPk(req.params.id); const channel = await Channel.findByPk(req.params.id);
@ -134,7 +139,9 @@ router.get("/channels/:id/run", async (req, res) => {
await updateLiveSchedulesForChannel(channel); await updateLiveSchedulesForChannel(channel);
res.send(`Live schedules successfully updated for channel: ${channel.name}`); res.send(
"<script>alert('スケジュールを取得しました。'); window.location.href = '/hyAdmin/channel';</script>"
);
} catch (error) { } catch (error) {
console.error("Error updating live schedules for channel:", error); console.error("Error updating live schedules for channel:", error);
res.status(500).send("Failed to update live schedules for this channel."); res.status(500).send("Failed to update live schedules for this channel.");
@ -142,7 +149,7 @@ router.get("/channels/:id/run", async (req, res) => {
}); });
// 配信履歴取得 // 配信履歴取得
router.post("/channels/:id/fetch-history", async (req, res) => { router.post("/channel/:id/fetch-history", async (req, res) => {
try { try {
const channelId = req.params.id; const channelId = req.params.id;
const { startDate, endDate } = req.body; const { startDate, endDate } = req.body;
@ -159,11 +166,84 @@ router.post("/channels/:id/fetch-history", async (req, res) => {
const historyStreams = await fetchPastLiveStreams(channel.youtube_id, startDate, endDate); const historyStreams = await fetchPastLiveStreams(channel.youtube_id, startDate, endDate);
await saveLiveSchedules(channel.id!, historyStreams); await saveLiveSchedules(channel.id!, historyStreams);
res.json({ message: "History fetched and saved successfully." }); res.send(
"<script>alert('過去の配信情報を取得しました。'); window.location.href = '/hyAdmin/channel';</script>"
);
} catch (error) { } catch (error) {
console.error("Failed to fetch history:", error); console.error("Failed to fetch history:", error);
res.status(500).json({ error: "Failed to fetch history." }); res.status(500).json({ error: "Failed to fetch history." });
} }
}); });
// LiveSchedules 一覧表示
router.get("/schedule", async (req, res) => {
try {
const liveSchedules = await LiveSchedule.findAll({
include: [{ model: Channel }],
order: [["start_time", "ASC"]],
});
res.render("admin/schedule", { liveSchedules, dayjs });
} catch (error) {
console.error("Failed to fetch live schedules:", error);
res.status(500).send("Internal Server Error");
}
});
// LiveSchedule 編集画面
router.get("/schedule/:id/edit", async (req, res) => {
try {
const schedule = await LiveSchedule.findByPk(req.params.id);
const channels = await Channel.findAll();
if (!schedule) {
return res.status(404).send("Schedule not found");
}
res.render("admin/schedule-edit", { schedule, channels, dayjs });
} catch (error) {
console.error("Failed to fetch schedule:", error);
res.status(500).send("Internal Server Error");
}
});
// LiveSchedule 更新処理
router.put("/schedule/:id", async (req, res) => {
try {
const { channel_id, title, start_time } = req.body;
const schedule = await LiveSchedule.findByPk(req.params.id);
if (!schedule) {
return res.status(404).send("Schedule not found");
}
await schedule.update({
channel_id,
title,
start_time: new Date(start_time),
});
res.redirect("/hyAdmin/schedule");
} catch (error) {
console.error("Failed to update schedule:", error);
res.status(500).send("Internal Server Error");
}
});
// LiveSchedule 削除
router.delete("/schedule/:id", async (req, res) => {
try {
const schedule = await LiveSchedule.findByPk(req.params.id);
if (!schedule) {
return res.status(404).send("Schedule not found");
}
await schedule.destroy();
res.redirect("/hyAdmin/schedule");
} catch (error) {
console.error("Failed to delete schedule:", error);
res.status(500).send("Internal Server Error");
}
});
export default router; export default router;

View File

@ -1,75 +0,0 @@
import { Router } from "express";
import { Channel, LiveSchedule } from "../models"; // Sequelize モデルをインポート
const router = Router();
// チャンネル一覧を取得
router.get("/channels", async (req, res) => {
try {
const channels = await Channel.findAll();
res.json(channels);
} catch (error) {
res.status(500).json({ error: "Failed to fetch channels" });
}
});
// // チャンネルを登録
// router.post("/channels", async (req, res) => {
// try {
// const { name, youtube_id } = req.body;
// const newChannel = await Channel.create({ name, youtube_id });
// res.status(201).json(newChannel);
// } catch (error) {
// res.status(400).json({ error: "Failed to create channel" });
// }
// });
// // チャンネルを更新
// router.put("/channels/:id", async (req, res) => {
// try {
// const { id } = req.params;
// const { name, youtube_id } = req.body;
// const channel = await Channel.findByPk(id);
// if (!channel) {
// return res.status(404).json({ error: "Channel not found" });
// }
// channel.name = name;
// channel.youtube_id = youtube_id;
// await channel.save();
// res.json(channel);
// } catch (error) {
// res.status(400).json({ error: "Failed to update channel" });
// }
// });
// // チャンネルを削除
// router.delete("/channels/:id", async (req, res) => {
// try {
// const { id } = req.params;
// const channel = await Channel.findByPk(id);
// if (!channel) {
// return res.status(404).json({ error: "Channel not found" });
// }
// await channel.destroy();
// res.status(204).send();
// } catch (error) {
// res.status(500).json({ error: "Failed to delete channel" });
// }
// });
// ライブスケジュール一覧を取得
router.get("/live-schedules", async (req, res) => {
try {
const schedules = await LiveSchedule.findAll({ include: [Channel] });
res.json(schedules);
} catch (error) {
res.status(500).json({ error: "Failed to fetch live schedules" });
}
});
export default router;

7
src/types/express-session.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import "express-session";
declare module "express-session" {
interface SessionData {
isAuthenticated?: boolean;
}
}

View File

@ -8,6 +8,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "include": ["src/**/*.ts", "src/types/**/*.d.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/hyAdmin/common.css">
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>チャンネルを編集</h1>
<form action="/hyAdmin/channel/<%= channel.id %>?_method=PUT" method="POST" class="admin-form">
<div class="form-group">
<label for="name">チャンネル名:</label>
<input type="text" id="name" name="name" value="<%= channel.name %>" required>
</div>
<div class="form-group">
<label for="channel_handle">チャンネルハンドル:</label>
<input type="text" id="channel_handle" name="channel_handle" value="<%= channel.channel_handle %>">
</div>
<div class="form-group">
<label for="youtube_id">YouTube ID:</label>
<input type="text" id="youtube_id" name="youtube_id" value="<%= channel.youtube_id %>" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-update">チャンネルを更新</button>
<a href="/hyAdmin/channel" class="btn btn-back">戻る</a>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/hyAdmin/common.css">
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>新しいチャンネルを追加</h1>
<form action="/hyAdmin/channel/new" method="POST" class="admin-form">
<div class="form-group">
<label for="name">チャンネル名:</label>
<input type="text" id="name" name="name" placeholder="例: Example Channel" required>
</div>
<div class="form-group">
<label for="channel_handle">チャンネルハンドル (例: @example):</label>
<input type="text" id="channel_handle" name="channel_handle" placeholder="例: @example">
</div>
<div class="form-group">
<label for="youtube_id">YouTube ID (例: UC_xxx...):</label>
<input type="text" id="youtube_id" name="youtube_id" placeholder="例: UC_xxx...">
</div>
<p class="form-note">注意: 「チャンネルハンドル」または「YouTube ID」のいずれかを必ず入力してください。</p>
<div class="form-actions">
<button type="submit" class="btn btn-add">チャンネルを追加</button>
<a href="/hyAdmin/channel" class="btn btn-back">戻る</a>
</div>
</form>
</div>
</body>
</html>

66
views/admin/channel.ejs Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/hyAdmin/common.css">
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>チャンネル管理</h1>
<div class="add-channel">
<a href="/hyAdmin/channel/new" class="btn">新しいチャンネルを追加</a>
</div>
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>チャンネル名</th>
<th>ハンドル</th>
<th>YouTube ID</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<% channels.forEach(channel => { %>
<tr>
<td><%= channel.id %></td>
<td><%= channel.name %></td>
<td><%= channel.channel_handle %></td>
<td><%= channel.youtube_id %></td>
<td>
<div class="action-buttons">
<form action="/hyAdmin/channel/<%= channel.id %>/edit" method="GET">
<button type="submit" class="btn btn-edit">編集</button>
</form>
<form action="/hyAdmin/channel/<%= channel.id %>?_method=DELETE" method="POST">
<button type="submit" class="btn btn-delete" onclick="return confirm('本当にこのチャンネルを削除しますか?');">削除</button>
</form>
<form action="/hyAdmin/channel/<%= channel.id %>/run" method="GET">
<button type="submit" class="btn btn-run">スケジュール取得</button>
</form>
<form action="/hyAdmin/channel/<%= channel.id %>/fetch-history" method="POST" class="history-form">
<div class="date-group">
<label>
開始日:
<input type="date" name="startDate" required>
</label>
<label>
終了日:
<input type="date" name="endDate" required>
</label>
<button type="submit" class="btn btn-fetch">履歴取得</button>
</div>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<%- include("../partials/head") %>
<body>
<h1>Channel List</h1>
<a href="/admin/channels/new">Add New Channel</a>
<table border="1">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Channel Handle</th>
<th>YouTube ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% channels.forEach(channel => { %>
<tr>
<td><%= channel.id %></td>
<td><%= channel.name %></td>
<td><%= channel.channel_handle %></td>
<td><%= channel.youtube_id %></td>
<td>
<form action="/admin/channels/<%= channel.id %>/edit" method="GET" style="display:inline;">
<button type="submit">Edit</button>
</form>
<form action="/admin/channels/<%= channel.id %>?_method=DELETE" method="POST" style="display:inline;">
<button type="submit" onclick="return confirm('Are you sure you want to delete this channel?');">Delete</button>
</form>
<form action="/admin/channels/<%= channel.id %>/run" method="GET" style="display:inline;">
<button type="submit">Run</button>
</form>
<form action="/admin/channels/<%= channel.id %>/fetch-history" method="POST" style="display:inline;">
<input type="date" name="startDate" required>
<input type="date" name="endDate" required>
<button type="submit">Fetch History</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</body>
</html>

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<%- include("../partials/head") %>
<body>
<h1><%= title %></h1>
<form action="/admin/channels/<%= channel.id %>?_method=PUT" method="POST">
<label for="name">Channel Name:</label>
<input type="text" id="name" name="name" value="<%= channel.name %>" required>
<br>
<label for="channel_handle">Channel Handle:</label>
<input type="text" id="channel_handle" name="channel_handle" value="<%= channel.channel_handle %>" required>
<br>
<label for="youtube_id">YouTube ID:</label>
<input type="text" id="youtube_id" name="youtube_id" value="<%= channel.youtube_id %>" required>
<br>
<button type="submit">Update Channel</button>
</form>
</body>
</html>

View File

@ -6,7 +6,13 @@
</head> </head>
<%- include("../partials/head") %> <%- include("../partials/head") %>
<body> <body>
<h1><%= title %></h1> <%- include("../partials/hyAdmin/header") %>
<p>Welcome to the Admin Dashboard!</p> <div class="header-spacer"></div>
<div class="main-content">
<h1>管理画面</h1>
<div class="admin-menu">
<a href="/hyAdmin/channel" class="btn">チャンネル管理</a><br>
<a href="/hyAdmin/schedule" class="btn">配信スケジュール管理</a>
</div>
</body> </body>
</html> </html>

View File

@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<%- include("../partials/head") %>
<body>
<h1>Add New Channel</h1>
<form action="/admin/channels/new" method="POST">
<label for="name">Channel Name (Optional):</label>
<input type="text" id="name" name="name">
<br>
<label for="channel_handle">Channel Handle (e.g., @example):</label>
<input type="text" id="channel_handle" name="channel_handle">
<br>
<label for="youtube_id">YouTube ID (e.g., UC_xxx...):</label>
<input type="text" id="youtube_id" name="youtube_id">
<br>
<p>Note: Either 'Channel Handle' or 'YouTube ID' must be provided.</p>
<button type="submit">Add Channel</button>
</form>
<a href="/admin/channels">Back to Channel List</a>
</body>
</html>

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/hyAdmin/common.css">
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>スケジュール編集</h1>
<form action="/hyAdmin/schedule/<%= schedule.id %>?_method=PUT" method="POST" class="admin-form">
<div class="form-group">
<label for="channel_id">チャンネル名:</label>
<select id="channel_id" name="channel_id" required>
<% channels.forEach(channel => { %>
<option value="<%= channel.id %>" <%= schedule.channel_id === channel.id ? "selected" : "" %>><%= channel.name %></option>
<% }) %>
</select>
</div>
<div class="form-group">
<label for="title">タイトル:</label>
<input type="text" id="title" name="title" value="<%= schedule.title %>" required>
</div>
<div class="form-group">
<label for="start_time">配信開始時刻:</label>
<input type="datetime-local" id="start_time" name="start_time" value="<%= dayjs(schedule.start_time).tz("Asia/Tokyo").format("YYYY-MM-DDTHH:mm") %>" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-update">更新</button>
<a href="/hyAdmin/schedule" class="btn btn-back">キャンセル</a>
</div>
</form>
</div>
</body>
</html>

45
views/admin/schedule.ejs Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/hyAdmin/common.css">
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>配信スケジュール管理</h1>
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>チャンネル名</th>
<th>タイトル</th>
<th>配信開始時刻</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<% liveSchedules.forEach(schedule => { %>
<tr>
<td><%= schedule.id %></td>
<td><%= schedule.Channel ? schedule.Channel.name : "N/A" %></td>
<td><%= schedule.title %></td>
<td><%= dayjs(schedule.start_time).tz("Asia/Tokyo").format("YYYY-MM-DD HH:mm") %></td>
<td>
<form action="/hyAdmin/schedule/<%= schedule.id %>/edit" method="GET" style="display:inline;">
<button class="btn btn-edit">編集</button>
</form>
<form action="/hyAdmin/schedule/<%= schedule.id %>?_method=DELETE" method="POST" style="display:inline;">
<button class="btn btn-delete" onclick="return confirm('本当に削除しますか?');">削除</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<%- include("../partials/head") %>
<body>
<h1><%= title %></h1>
<h2>Channel: <%= channel.name %> (<%= channel.youtube_id %>)</h2>
<h3>Live Streams</h3>
<% if (liveStreams.length === 0) { %>
<p>No upcoming live streams found.</p>
<% } else { %>
<ul>
<% liveStreams.forEach(stream => { %>
<li>
<strong>Title:</strong> <%= stream.title %><br>
<strong>Start Time:</strong> <%= new Date(stream.start_time).toLocaleString() %><br>
<strong>Thumbnail:</strong> <img src="<%= stream.thumbnail_url %>" alt="Thumbnail" width="200">
</li>
<% }) %>
</ul>
<% } %>
<a href="/admin/channels">Back to Channel List</a>
</body>
</html>

View File

@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/errors.css">
</head> </head>
<%- include("../partials/head") %> <%- include("../partials/head") %>
<body> <body>
<%- include("../partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="error-container"> <div class="error-container">
<h1>400</h1> <h1>400</h1>
<p>送信されたリクエストに問題がありました。</p> <p>送信されたリクエストに問題がありました。</p>
<a href="/" class="home-link">トップページへ戻る</a> <a href="/" class="home-link">トップページへ戻る</a>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/errors.css">
</head> </head>
<%- include("../partials/head") %> <%- include("../partials/head") %>
<body> <body>
<%- include("../partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="error-container"> <div class="error-container">
<h1>403</h1> <h1>403</h1>
<p>このページへのアクセスが拒否されました。</p> <p>このページへのアクセスが拒否されました。</p>
<a href="/" class="home-link">トップページへ戻る</a> <a href="/" class="home-link">トップページへ戻る</a>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/errors.css">
</head> </head>
<%- include("../partials/head") %> <%- include("../partials/head") %>
<body> <body>
<%- include("../partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="error-container"> <div class="error-container">
<h1>404</h1> <h1>404</h1>
<p>お探しのページが見つかりませんでした。</p> <p>お探しのページが見つかりませんでした。</p>
<a href="/" class="home-link">トップページへ戻る</a> <a href="/" class="home-link">トップページへ戻る</a>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/errors.css">
</head> </head>
<%- include("../partials/head") %> <%- include("../partials/head") %>
<body> <body>
<%- include("../partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="error-container"> <div class="error-container">
<h1>500</h1> <h1>500</h1>
<p>予期せぬエラーが発生しました。少し時間をおいて再度お試しください。</p> <p>予期せぬエラーが発生しました。少し時間をおいて再度お試しください。</p>
<a href="/" class="home-link">トップページへ戻る</a> <a href="/" class="home-link">トップページへ戻る</a>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/errors.css">
</head> </head>
<%- include("../partials/head") %> <%- include("../partials/head") %>
<body> <body>
<%- include("../partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="error-container"> <div class="error-container">
<h1>503</h1> <h1>503</h1>
<p>現在サーバーが一時的に利用できません。時間をおいて再度お試しください。</p> <p>現在サーバーが一時的に利用できません。時間をおいて再度お試しください。</p>
<a href="/" class="home-link">トップページへ戻る</a> <a href="/" class="home-link">トップページへ戻る</a>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<a href="/">神椿配信スケジュール</a> <span>神椿配信スケジュール</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<a href="/terms" class="header-link">利用規約</a> <a href="/terms" class="header-link">利用規約</a>

View File

@ -0,0 +1,11 @@
<head>
<link rel="stylesheet" href="/css/hyAdmin/header.css">
</head>
<header class="header">
<div class="header-left">
<a href="/hyAdmin">神椿配信スケジュール(管理画面)</a>
</div>
<div class="header-right">
<a href="/" class="header-link">一般ページへ</a>
</div>
</header>

View File

@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/schedule.css"> <link rel="stylesheet" href="/css/schedule.css">
<link rel="stylesheet" href="/css/header.css">
</head> </head>
<%- include("partials/head") %> <%- include("partials/head") %>
<body> <body>
@ -19,7 +18,13 @@
</div> </div>
<div class="banners"> <div class="banners">
<% groupedSchedules[date].forEach(schedule => { %> <% groupedSchedules[date].forEach(schedule => { %>
<a href="https://www.youtube.com/watch?v=<%= schedule.video_id %>" class="banner" target="_blank"> <%
const timeDifference = dayjs(schedule.start_time).diff(dayjs(), 'minute');
const isLive = timeDifference < 5 && timeDifference >= -60; // 配信開始5時間前1時間後
%>
<a href="https://www.youtube.com/watch?v=<%= schedule.video_id %>"
class="banner <%= isLive ? 'highlight-live' : '' %>"
target="_blank">
<div class="thumbnail"> <div class="thumbnail">
<img src="<%= schedule.thumbnail_url || '/default-thumbnail.jpg' %>" alt="Thumbnail"> <img src="<%= schedule.thumbnail_url || '/default-thumbnail.jpg' %>" alt="Thumbnail">
</div> </div>

View File

@ -3,12 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/terms.css"> <link rel="stylesheet" href="/css/terms.css">
</head> </head>
<%- include("partials/head") %> <%- include("partials/head") %>
<body> <body>
<%- include("partials/header") %> <%- include("partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="terms-container"> <div class="terms-container">
<h1>利用規約</h1> <h1>利用規約</h1>
<section> <section>
@ -41,6 +42,10 @@
本サイトに関するお問い合わせは、管理者宛てにご連絡ください。ただし、返信や対応を保証するものではありません。 本サイトに関するお問い合わせは、管理者宛てにご連絡ください。ただし、返信や対応を保証するものではありません。
</p> </p>
</section> </section>
<div class="back-to-top">
<a href="/" class="btn-back">トップページに戻る</a>
</div>
</div>
</div> </div>
</body> </body>
</html> </html>