利用規約の追加とエラーハンドリング、トップページの修正

This commit is contained in:
ntki72 2024-12-26 22:14:43 +09:00
parent c804494541
commit 29cebe239f
22 changed files with 540 additions and 214 deletions

View File

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

31
public/css/errors.css Normal file
View File

@ -0,0 +1,31 @@
.error-container {
text-align: center;
margin-top: 100px;
font-family: Arial, sans-serif;
}
.error-container h1 {
font-size: 5em;
margin-bottom: 20px;
color: #ff6b6b;
}
.error-container p {
font-size: 1.5em;
margin-bottom: 30px;
color: #333;
}
.home-link {
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: #fff;
text-decoration: none;
border-radius: 5px;
font-size: 1em;
}
.home-link:hover {
background-color: #0056b3;
}

48
public/css/header.css Normal file
View File

@ -0,0 +1,48 @@
.header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 60px;
background-color: #333;
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,59 +1,69 @@
.schedule-container {
width: 80%;
margin: 0 auto;
}
.date-header {
font-size: 1.5em;
margin: 20px 0;
font-weight: bold;
}
.banners {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.banner {
display: flex;
flex-direction: row;
align-items: center;
text-decoration: none;
color: black;
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
width: calc(50% - 10px); /* 2列になるように調整 */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.banner:hover {
transform: scale(1.02);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15);
}
.thumbnail img {
width: 150px;
height: auto;
object-fit: cover;
}
.details {
padding: 10px;
flex: 1;
}
.time-channel {
font-size: 1em;
margin-bottom: 5px;
color: #333;
}
.title {
font-size: 1.2em;
font-weight: bold;
color: #555;
}
width: 80%;
margin: 0 auto;
}
.date-header {
font-size: 1.5em;
margin: 20px 0;
font-weight: bold;
min-width: 550px;
}
.banners {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.banner {
display: flex;
flex-direction: row;
align-items: center;
text-decoration: none;
color: black;
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
flex: 1 1 calc(50% - 13px);
max-width: calc(50% - 13px);
min-width: 550px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.banner:hover {
transform: scale(1.02);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15);
}
.thumbnail img {
width: 150px;
height: auto;
object-fit: cover;
}
.details {
padding: 10px;
flex: 1;
}
.time-channel {
font-size: 1em;
margin-bottom: 5px;
color: #333;
}
.title {
font-size: 1.2em;
font-weight: bold;
color: #555;
}
.header-spacer {
height: 60px;
}
.main-content {
margin-top: 20px;
}

47
public/css/terms.css Normal file
View File

@ -0,0 +1,47 @@
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.terms-container {
max-width: 800px;
margin: 80px auto; /* ヘッダーの高さを考慮して調整 */
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 2em;
margin-bottom: 20px;
text-align: center;
color: #333333;
}
h2 {
font-size: 1.5em;
margin-top: 20px;
margin-bottom: 10px;
color: #444444;
border-bottom: 2px solid #ddd;
padding-bottom: 5px;
}
p {
font-size: 1em;
line-height: 1.6;
margin-bottom: 15px;
color: #555555;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@ -3,6 +3,7 @@ import cron from "node-cron";
import express from "express";
import apiRoutes from "./routes/api";
import adminRoutes from "./routes/admin";
import userRoutes from "./routes/user";
import basicAuth from "express-basic-auth";
import { runMigrations } from "./utils/migrate";
import methodOverride from "method-override";
@ -46,16 +47,33 @@ const startServer = async () => {
// 管理画面ルート
app.use("/admin", adminRoutes);
// ユーザー向けルート
app.use("/", userRoutes);
// 管理画面 API
app.use("/admin/api", apiRoutes);
// ユーザー向けルート
// トップページ
app.use("/", scheduleRoutes);
// 静的ファイルの提供
app.use("/thumbnails", express.static("public/thumbnails"));
app.use("/css", express.static("public/css"));
app.use("/js", express.static("public/js"));
// エラーハンドリング
app.use((req, res, next) => {
res.status(404).render("errors/404");
});
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error("Unhandled error:", err);
if (err.message.includes("permission")) {
res.status(403).render("errors/403");
} else if (res.statusCode === 503) {
res.status(503).render("errors/503");
} else {
res.status(500).render("errors/500");
}
});
// サーバー起動
app.listen(WEB_PORT, () => {

View File

@ -1,5 +1,6 @@
import express from "express";
import { LiveSchedule, Channel } from "../models";
import { Op } from "sequelize";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
@ -11,8 +12,14 @@ const router = express.Router();
router.get("/", async (req, res) => {
try {
const now = dayjs().tz("Asia/Tokyo").startOf("day"); // 当日0時
const liveSchedules = await LiveSchedule.findAll({
include: [{ model: Channel }],
where: {
start_time: {
[Op.gte]: now.toDate(), // Sequelize の Op を使用
},
},
order: [["start_time", "ASC"]],
});

14
src/routes/user.ts Normal file
View File

@ -0,0 +1,14 @@
import express from "express";
import scheduleRoutes from "./schedule";
const router = express.Router();
// スケジュールページ(トップページ)
router.use("/", scheduleRoutes);
// 利用規約ページ
router.get("/terms", (req, res) => {
res.render("terms");
});
export default router;

View File

@ -1,49 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Channel List</title>
</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 => { %>
<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>
<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>
<th>ID</th>
<th>Name</th>
<th>Channel Handle</th>
<th>YouTube ID</th>
<th>Actions</th>
</tr>
<% }) %>
</tbody>
</table>
</body>
</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 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</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 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

@ -1,12 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to the Admin Dashboard!</p>
</body>
<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>
<p>Welcome to the Admin Dashboard!</p>
</body>
</html>

View File

@ -1,25 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add New Channel</title>
</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 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

@ -1,27 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</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 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>

16
views/errors/400.ejs Normal file
View File

@ -0,0 +1,16 @@
<!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/errors.css">
</head>
<%- include("../partials/head") %>
<body>
<div class="error-container">
<h1>400</h1>
<p>送信されたリクエストに問題がありました。</p>
<a href="/" class="home-link">トップページへ戻る</a>
</div>
</body>
</html>

16
views/errors/403.ejs Normal file
View File

@ -0,0 +1,16 @@
<!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/errors.css">
</head>
<%- include("../partials/head") %>
<body>
<div class="error-container">
<h1>403</h1>
<p>このページへのアクセスが拒否されました。</p>
<a href="/" class="home-link">トップページへ戻る</a>
</div>
</body>
</html>

16
views/errors/404.ejs Normal file
View File

@ -0,0 +1,16 @@
<!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/errors.css">
</head>
<%- include("../partials/head") %>
<body>
<div class="error-container">
<h1>404</h1>
<p>お探しのページが見つかりませんでした。</p>
<a href="/" class="home-link">トップページへ戻る</a>
</div>
</body>
</html>

16
views/errors/500.ejs Normal file
View File

@ -0,0 +1,16 @@
<!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/errors.css">
</head>
<%- include("../partials/head") %>
<body>
<div class="error-container">
<h1>500</h1>
<p>予期せぬエラーが発生しました。少し時間をおいて再度お試しください。</p>
<a href="/" class="home-link">トップページへ戻る</a>
</div>
</body>
</html>

16
views/errors/503.ejs Normal file
View File

@ -0,0 +1,16 @@
<!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/errors.css">
</head>
<%- include("../partials/head") %>
<body>
<div class="error-container">
<h1>503</h1>
<p>現在サーバーが一時的に利用できません。時間をおいて再度お試しください。</p>
<a href="/" class="home-link">トップページへ戻る</a>
</div>
</body>
</html>

9
views/partials/head.ejs Normal file
View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>神椿配信スケジュール</title>
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/errors.css">
</head>

View File

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

View File

@ -1,34 +1,41 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schedule</title>
<link rel="stylesheet" href="/css/schedule.css">
</head>
<body>
<div class="schedule-container">
<% Object.keys(groupedSchedules).forEach(date => { %>
<div class="date-header">
<%= dayjs(date).tz("Asia/Tokyo").format("YYYY年MM月DD日 (ddd)") %>
</div>
<div class="banners">
<% groupedSchedules[date].forEach(schedule => { %>
<a href="https://www.youtube.com/watch?v=<%= schedule.video_id %>" class="banner" target="_blank">
<div class="thumbnail">
<img src="<%= schedule.thumbnail_url || '/default-thumbnail.jpg' %>" alt="Thumbnail">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/schedule.css">
<link rel="stylesheet" href="/css/header.css">
</head>
<%- include("partials/head") %>
<body>
<%- include("partials/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<div class="content">
<div class="schedule-container">
<% Object.keys(groupedSchedules).forEach(date => { %>
<div class="date-header">
<%= dayjs(date).tz("Asia/Tokyo").format("YYYY年MM月DD日 (ddd)") %>
</div>
<div class="details">
<div class="time-channel">
<span class="time"><%= dayjs(schedule.start_time).tz("Asia/Tokyo").format("HH:mm") %></span>
<span class="channel-name">/ <%= schedule.Channel.name %></span>
</div>
<div class="title"><%= schedule.title %></div>
<div class="banners">
<% groupedSchedules[date].forEach(schedule => { %>
<a href="https://www.youtube.com/watch?v=<%= schedule.video_id %>" class="banner" target="_blank">
<div class="thumbnail">
<img src="<%= schedule.thumbnail_url || '/default-thumbnail.jpg' %>" alt="Thumbnail">
</div>
<div class="details">
<div class="time-channel">
<span class="time"><%= dayjs(schedule.start_time).tz("Asia/Tokyo").format("HH:mm") %></span>
<span class="channel-name">/ <%= schedule.Channel.name %></span>
</div>
<div class="title"><%= schedule.title %></div>
</div>
</a>
<% }) %>
</div>
</a>
<% }) %>
<% }) %>
</div>
</div>
<% }) %>
</div>
</body>
</div>
</body>
</html>

46
views/terms.ejs Normal file
View File

@ -0,0 +1,46 @@
<!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/header.css">
<link rel="stylesheet" href="/css/terms.css">
</head>
<%- include("partials/head") %>
<body>
<%- include("partials/header") %>
<div class="terms-container">
<h1>利用規約</h1>
<section>
<h2>サイトの運営について</h2>
<p>
本サイトは、<strong>KAMITSUBAKI STUDIO</strong>およびその関連企業・団体とは一切関係のない非公式のウェブサイトです。<br>
本サイトは、個人が趣味の一環として運営しており、情報の正確性や完全性について保証するものではありません。
</p>
</section>
<section>
<h2>免責事項</h2>
<p>
本サイトに掲載されている情報は、できる限り正確であるよう努めていますが、その内容に誤りがある場合や、<br>
配信スケジュールの変更・キャンセル等に関して責任を負うものではありません。
</p>
<p>
また、本サイトを利用することで生じた損害について、一切の責任を負いません。利用者はご自身の責任において本サイトをご利用ください。<br>
</p>
</section>
<section>
<h2>著作権について</h2>
<p>
本サイトに掲載されている文章や画像などの著作物は、著作権法で認められる範囲内で利用していますが、<br>
著作権者からの要請があった場合、速やかに対応いたします。
</p>
</section>
<section>
<h2>お問い合わせ</h2>
<p>
本サイトに関するお問い合わせは、管理者宛てにご連絡ください。ただし、返信や対応を保証するものではありません。
</p>
</section>
</div>
</body>
</html>