first commit

This commit is contained in:
2025-12-30 21:47:39 +09:00
commit 0a37314fa8
47 changed files with 6088 additions and 0 deletions

280
web/static/css/style.css Normal file
View File

@@ -0,0 +1,280 @@
/* Custom styles for Homework Manager */
:root {
--primary-color: #4361ee;
--secondary-color: #3f37c9;
--success-color: #4cc9f0;
--warning-color: #f72585;
--danger-color: #e63946;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
margin-top: 0 !important;
padding-top: 0 !important;
}
.countdown {
font-family: 'Courier New', Courier, monospace;
font-weight: bold;
color: var(--danger-color);
}
main {
flex: 1;
}
/* Card enhancements */
.card {
border: none;
border-radius: 0.75rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.card-header {
background-color: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
font-weight: 600;
}
/* Navbar customization */
.navbar {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
font-weight: 700;
}
/* Table improvements */
.table {
background-color: #fff;
border-radius: 0.5rem;
overflow: hidden;
}
.table th {
font-weight: 600;
border-bottom-width: 1px;
}
.table-hover tbody tr:hover {
background-color: rgba(67, 97, 238, 0.05);
}
/* Button styles */
.btn {
border-radius: 0.5rem;
font-weight: 500;
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--secondary-color);
border-color: var(--secondary-color);
}
/* Badge styles */
.badge {
font-weight: 500;
padding: 0.4em 0.8em;
}
/* Form styles */
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25);
}
/* Alert enhancements */
.alert {
border: none;
border-radius: 0.5rem;
}
/* Stats cards */
.card.bg-primary,
.card.bg-warning,
.card.bg-info,
.card.bg-danger {
border-radius: 1rem;
}
.card.bg-primary:hover,
.card.bg-warning:hover,
.card.bg-info:hover,
.card.bg-danger:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Tabs - Removed conflicted specific styles as they are managed in templates */
.nav-tabs .nav-link {
font-weight: 500;
}
/* Footer */
.footer {
border-top: 1px solid #e9ecef;
}
/* Login/Register cards */
.card.shadow {
border-radius: 1rem;
}
/* Empty states */
.text-muted.display-1 {
color: #dee2e6 !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card-body {
padding: 0.75rem;
}
.table-responsive {
font-size: 0.85rem;
}
}
.table td,
.table th {
padding: 0.35rem 0.5rem !important;
}
.page-header {
margin-bottom: 0.75rem !important;
}
/* Animations for Anxiety/Urgency */
@keyframes pulse-bg {
0%,
100% {
background-color: #fff3cd;
}
50% {
background-color: #ffe69c;
}
}
@keyframes pulse-bg-danger {
0%,
100% {
background-color: #f8d7da;
}
50% {
background-color: #f5c2c7;
}
}
@keyframes blink-text {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.anxiety-warning {
animation: pulse-bg 2s infinite;
}
.anxiety-danger {
animation: pulse-bg-danger 1s infinite;
}
/* Custom Table Styles - Compact */
.custom-table thead th {
border-bottom: 2px solid #dee2e6;
font-size: 0.8rem;
color: #495057;
font-weight: 600;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
white-space: nowrap;
}
.custom-table tbody tr {
transition: background-color 0.2s;
}
.custom-table tbody td {
border-bottom: 1px solid #dee2e6;
vertical-align: middle;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
font-size: 0.9rem;
}
.custom-table tbody tr:last-child td {
border-bottom: none;
}
/* Compact form elements */
.form-control-custom,
.form-select-custom,
.btn-custom {
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
border-radius: 0.25rem;
}
/* Custom Tab Styles */
.nav-tabs .nav-link {
font-size: 0.9rem;
padding: 0.5rem 1rem;
color: #6c757d;
/* text-muted */
border: none;
}
.nav-tabs .nav-link:hover {
color: #000;
border: none;
}
.nav-tabs .nav-link.active {
color: #000 !important;
font-weight: 700;
border-bottom: 3px solid #000 !important;
background: transparent;
}
/* Status Icon Size */
.bi-circle,
.bi-check-circle-fill {
font-size: 1rem;
}
/* Urgency styles for countdown */
.countdown-urgent {
color: #dc3545;
font-weight: 700;
animation: blink-text 1s infinite;
}
.countdown-warning {
color: #fd7e14;
font-weight: 700;
}

33
web/static/js/app.js Normal file
View File

@@ -0,0 +1,33 @@
// Homework Manager JavaScript
document.addEventListener('DOMContentLoaded', function() {
// Auto-dismiss alerts after 5 seconds
const alerts = document.querySelectorAll('.alert:not(.alert-danger)');
alerts.forEach(function(alert) {
setTimeout(function() {
alert.classList.add('fade');
setTimeout(function() {
alert.remove();
}, 150);
}, 5000);
});
// Confirm dialogs for dangerous actions
const confirmForms = document.querySelectorAll('form[data-confirm]');
confirmForms.forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm(form.dataset.confirm)) {
e.preventDefault();
}
});
});
// Set default datetime to now + 1 day for new assignments
const dueDateInput = document.getElementById('due_date');
if (dueDateInput && !dueDateInput.value) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(23, 59, 0, 0);
dueDateInput.value = tomorrow.toISOString().slice(0, 16);
}
});

View File

@@ -0,0 +1,115 @@
{{template "base" .}}
{{define "content"}}
<h1 class="mb-4"><i class="bi bi-key me-2"></i>APIキー管理</h1>
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
{{if .newKey}}
<div class="alert alert-success">
<h5 class="alert-heading"><i class="bi bi-check-circle me-2"></i>APIキーが作成されました</h5>
<p class="mb-2">キー名: <strong>{{.newKeyName}}</strong></p>
<p class="mb-0">以下のキーを安全な場所に保存してください。このキーは二度と表示されません。</p>
<hr>
<div class="d-flex align-items-center">
<code class="flex-grow-1 bg-dark text-light p-2 rounded me-2" id="newApiKey">{{.newKey}}</code>
<button class="btn btn-outline-secondary" onclick="copyKey()"><i class="bi bi-clipboard"></i></button>
</div>
</div>
{{end}}
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-plus-circle me-2"></i>新規APIキー作成
</div>
<div class="card-body">
<form action="/admin/api-keys" method="POST" class="row g-3">
{{.csrfField}}
<div class="col-md-8">
<input type="text" class="form-control" name="name" placeholder="キー名(例: 外部連携用)" required>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus me-1"></i>作成</button>
</div>
</form>
</div>
</div>
{{if .apiKeys}}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>ID</th>
<th>キー名</th>
<th>作成者</th>
<th>最終使用</th>
<th>作成日</th>
<th style="width: 100px">操作</th>
</tr>
</thead>
<tbody>
{{range .apiKeys}}
<tr>
<td>{{.ID}}</td>
<td><i class="bi bi-key me-1"></i>{{.Name}}</td>
<td>{{if .User}}{{.User.Name}}{{else}}-{{end}}</td>
<td>{{if .LastUsed}}{{formatDateTime .LastUsed}}{{else}}<span class="text-muted">未使用</span>{{end}}</td>
<td>{{formatDate .CreatedAt}}</td>
<td>
<form action="/admin/api-keys/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('このAPIキーを削除しますか')">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="削除"><i
class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-key display-1 text-muted"></i>
<h3 class="mt-3">APIキーがありません</h3>
<p class="text-muted">上のフォームから新しいAPIキーを作成してください。</p>
</div>
{{end}}
<div class="card mt-4">
<div class="card-header">
<i class="bi bi-info-circle me-2"></i>API使用方法
</div>
<div class="card-body">
<p class="mb-2">APIにアクセスするには、<code>Authorization</code>ヘッダーにAPIキーを設定してください</p>
<pre
class="bg-dark text-light p-3 rounded"><code>curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8080/api/v1/assignments</code></pre>
<h6 class="mt-3">利用可能なエンドポイント:</h6>
<ul class="mb-0">
<li><code>GET /api/v1/assignments</code> - 課題一覧取得</li>
<li><code>GET /api/v1/assignments/pending</code> - 未完了の課題一覧</li>
<li><code>GET /api/v1/assignments/completed</code> - 完了済みの課題一覧</li>
<li><code>GET /api/v1/assignments/overdue</code> - 期限切れの課題一覧</li>
<li><code>GET /api/v1/assignments/due-today</code> - 今日が期限の課題一覧</li>
<li><code>GET /api/v1/assignments/due-this-week</code> - 今週中が期限の課題一覧</li>
<li><code>GET /api/v1/assignments/:id</code> - 課題詳細取得</li>
<li><code>POST /api/v1/assignments</code> - 課題作成</li>
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
</ul>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
function copyKey() {
const key = document.getElementById('newApiKey').innerText;
navigator.clipboard.writeText(key).then(() => {
alert('APIキーをコピーしました');
});
}
</script>
{{end}}

View File

@@ -0,0 +1,65 @@
{{template "base" .}}
{{define "content"}}
<h1 class="mb-4"><i class="bi bi-people me-2"></i>ユーザー管理</h1>
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
{{if .users}}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>ID</th>
<th>名前</th>
<th>メールアドレス</th>
<th>ロール</th>
<th>登録日</th>
<th style="width: 200px">操作</th>
</tr>
</thead>
<tbody>
{{range .users}}
<tr {{if eq .ID $.currentUserID}}class="table-primary" {{end}}>
<td>{{.ID}}</td>
<td>{{.Name}}{{if eq .ID $.currentUserID}}<span class="badge bg-info ms-2">自分</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{if eq .Role "admin"}}<span class="badge bg-danger">管理者</span>{{else}}<span
class="badge bg-secondary">ユーザー</span>{{end}}</td>
<td>{{formatDate .CreatedAt}}</td>
<td>
{{if ne .ID $.currentUserID}}
<form action="/admin/users/{{.ID}}/role" method="POST" class="d-inline" {{if eq .Role "admin"
}}onsubmit="return confirm('このユーザーを一般ユーザーに降格しますか?')"
{{else}}onsubmit="return confirm('このユーザーを管理者に昇格しますか?')" {{end}}>
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
{{if eq .Role "admin"}}
<input type="hidden" name="role" value="user">
<button type="submit" class="btn btn-sm btn-outline-secondary" title="ユーザーに降格"><i
class="bi bi-arrow-down"></i></button>
{{else}}
<input type="hidden" name="role" value="admin">
<button type="submit" class="btn btn-sm btn-outline-warning" title="管理者に昇格"><i
class="bi bi-arrow-up"></i></button>
{{end}}
</form>
<form action="/admin/users/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('このユーザーを削除しますか?')">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="削除"><i
class="bi bi-trash"></i></button>
</form>
{{else}}<span class="text-muted">-</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-people display-1 text-muted"></i>
<h3 class="mt-3">ユーザーがいません</h3>
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,51 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-pencil me-2"></i>課題編集</h5>
</div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/assignments/{{.assignment.ID}}">
{{.csrfField}}
<div class="mb-3">
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="title" name="title" value="{{.assignment.Title}}"
required>
</div>
<div class="mb-3">
<label for="subject" class="form-label">科目</label>
<input type="text" class="form-control" id="subject" name="subject"
value="{{.assignment.Subject}}">
</div>
<div class="mb-3">
<label for="priority" class="form-label">重要度</label>
<select class="form-select" id="priority" name="priority">
<option value="low" {{if eq .assignment.Priority "low" }}selected{{end}}></option>
<option value="medium" {{if eq .assignment.Priority "medium" }}selected{{end}}></option>
<option value="high" {{if eq .assignment.Priority "high" }}selected{{end}}></option>
</select>
</div>
<div class="mb-3">
<label for="due_date" class="form-label">提出期限 <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" id="due_date" name="due_date"
value="{{formatDateInput .assignment.DueDate}}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">説明</label>
<textarea class="form-control" id="description" name="description"
rows="3">{{.assignment.Description}}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,270 @@
{{template "base" .}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<h4 class="mb-0 fw-bold"><i class="bi bi-list-task me-2"></i>課題一覧</h4>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-secondary text-white" onclick="toggleCountdown()" id="toggleCountdownBtn">
<i class="bi bi-clock me-1"></i><span id="countdownBtnText">カウントダウン表示中</span>
</button>
<a href="/assignments/new" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-1"></i>新規登録
</a>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
<li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " pending"}}fw-bold border-bottom border-dark
border-3{{else}}text-muted{{end}}" href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}"
style="{{if eq .filter " pending"}}color: black !important;{{end}}">未完了</a>
</li>
<li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " completed"}}fw-bold border-bottom border-dark
border-3{{else}}text-muted{{end}}" href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}"
style="{{if eq .filter " completed"}}color: black !important;{{end}}">完了済み</a>
</li>
<li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " overdue"}}fw-bold border-bottom border-dark
border-3{{else}}text-muted{{end}}" href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}"
style="{{if eq .filter " overdue"}}color: black !important;{{end}}">期限切れ</a>
</li>
</ul>
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
<!-- Filter Section -->
<form action="/assignments" method="GET" class="row g-2 mb-3 align-items-center">
<input type="hidden" name="filter" value="{{.filter}}">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text bg-white border-end-0 text-muted"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0 ps-0 bg-white" name="q" placeholder="検索..."
value="{{.query}}">
</div>
</div>
<div class="col-md-4">
<select class="form-select form-select-sm bg-white" name="priority" onchange="this.form.submit()">
<option value="">全ての重要度</option>
<option value="high" {{if eq .priority "high" }}selected{{end}}></option>
<option value="medium" {{if eq .priority "medium" }}selected{{end}}></option>
<option value="low" {{if eq .priority "low" }}selected{{end}}></option>
</select>
</div>
<div class="col-md-3">
<a href="/assignments?filter={{.filter}}" class="btn btn-sm btn-outline-secondary w-100 bg-white">
クリア
</a>
</div>
</form>
<!-- Table -->
<div class="card shadow-sm border-0 rounded-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 custom-table">
<thead class="bg-secondary-subtle">
<tr>
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
<th class="text-dark fw-bold">タイトル</th>
<th style="width: 120px;" class="text-dark fw-bold">科目</th>
<th style="width: 80px;" class="text-dark fw-bold">重要度</th>
<th style="width: 140px;" class="text-dark fw-bold">期限</th>
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
</tr>
</thead>
<tbody>
{{range .assignments}}
<tr class="assignment-row border-bottom" data-due-ts="{{.DueDate.Unix}}"
data-completed="{{.IsCompleted}}">
<td class="ps-3 text-center">
{{if .IsCompleted}}
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit"
class="btn btn-link p-0 text-success text-decoration-none hover-dark"
title="未完了に戻す">
<i class="bi bi-check-circle-fill"></i>
</button>
</form>
{{else}}
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit"
class="btn btn-link p-0 text-secondary text-decoration-none hover-dark"
title="完了にする">
<i class="bi bi-circle"></i>
</button>
</form>
{{end}}
</td>
<td>
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
</td>
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
<td>
{{if eq .Priority "high"}}
<span class="badge bg-danger text-white border-0 fw-bold small"></span>
{{else if eq .Priority "medium"}}
<span class="badge bg-warning text-dark border-0 fw-bold small"></span>
{{else}}
<span class="badge bg-dark text-white border-0 fw-bold small"></span>
{{end}}
</td>
<td>
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
</div>
</td>
<td class="countdown-col">
{{if not .IsCompleted}}
<span class="countdown small fw-bold font-monospace text-dark">...</span>
{{else}}
<span class="text-secondary small fw-bold">-</span>
{{end}}
</td>
<td class="text-end pe-3">
<div class="btn-group">
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
<i class="bi bi-pencil-fill"></i>
</a>
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('削除しますか?');">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit"
class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent">
<i class="bi bi-trash-fill"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="text-center py-4 text-secondary fw-bold small">
課題なし
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt .totalPages 1}}
<div class="card-footer bg-white border-top-0 py-2">
<nav>
<ul class="pagination pagination-sm justify-content-center mb-0">
<li class="page-item {{if not .hasPrev}}disabled{{end}}">
<a class="page-link border-0 text-secondary"
href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
<li class="page-item disabled">
<span class="page-link border-0 text-dark fw-bold">{{.currentPage}} / {{.totalPages}}</span>
</li>
<li class="page-item {{if not .hasNext}}disabled{{end}}">
<a class="page-link border-0 text-secondary"
href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
{{end}}
</div>
<script>
function updateCountdowns() {
const now = new Date();
document.querySelectorAll('.assignment-row').forEach(row => {
if (row.getAttribute('data-completed') === 'true') return;
const dueTs = row.getAttribute('data-due-ts');
if (!dueTs) return;
// Fix: Use timestamp directly to avoid parsing issues
const due = new Date(parseInt(dueTs) * 1000);
if (isNaN(due.getTime())) return;
const diff = due - now;
const countdownEl = row.querySelector('.countdown');
// Reset classes
row.classList.remove('anxiety-danger', 'anxiety-warning', 'bg-danger-subtle');
if (countdownEl) countdownEl.className = 'countdown small fw-bold font-monospace';
if (diff < 0) {
if (countdownEl) {
countdownEl.textContent = "期限切れ";
countdownEl.classList.add('text-danger');
}
row.classList.add('bg-danger-subtle');
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
let text = "";
let remainingHours = (days * 24) + hours;
if (days > 0) {
text += `${days}`;
}
text += `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
if (countdownEl) countdownEl.textContent = text;
// Anxiety Logic
if (remainingHours < 24) {
row.classList.add('anxiety-danger');
if (countdownEl) {
countdownEl.classList.add('text-danger', 'countdown-urgent');
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1"></i>' + text;
}
} else if (days < 7) {
row.classList.add('anxiety-warning');
if (countdownEl) {
countdownEl.classList.add('text-dark');
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>' + text;
}
} else {
if (countdownEl) countdownEl.classList.add('text-secondary');
}
});
}
function toggleCountdown() {
const cols = document.querySelectorAll('.countdown-col');
const btnText = document.getElementById('countdownBtnText');
const isHidden = cols[0] && cols[0].style.display === 'none';
cols.forEach(col => {
col.style.display = isHidden ? '' : 'none';
});
btnText.textContent = isHidden ? 'カウントダウン表示中' : 'カウントダウン非表示中';
localStorage.setItem('countdownHidden', !isHidden);
}
// Init with higher frequency for smooth panic
setInterval(updateCountdowns, 1000);
updateCountdowns();
// Check preference
const isHidden = localStorage.getItem('countdownHidden') === 'true';
if (isHidden) {
document.querySelectorAll('.countdown-col').forEach(col => {
col.style.display = 'none';
});
const btnText = document.getElementById('countdownBtnText');
if (btnText) btnText.textContent = 'カウントダウン非表示中';
}
</script>
{{end}}

View File

@@ -0,0 +1,51 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>課題登録</h5>
</div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/assignments">
{{.csrfField}}
<div class="mb-3">
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="title" name="title" value="{{.formTitle}}" required
autofocus>
</div>
<div class="mb-3">
<label for="subject" class="form-label">科目</label>
<input type="text" class="form-control" id="subject" name="subject" value="{{.subject}}"
placeholder="例: 数学、英語、情報">
</div>
<div class="mb-3">
<label for="priority" class="form-label">重要度</label>
<select class="form-select" id="priority" name="priority">
<option value="low" {{if eq .priority "low" }}selected{{end}}></option>
<option value="medium" {{if not (or (eq .priority "low" ) (eq .priority "high"
))}}selected{{end}}></option>
<option value="high" {{if eq .priority "high" }}selected{{end}}></option>
</select>
</div>
<div class="mb-3">
<label for="due_date" class="form-label">提出期限 <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" id="due_date" name="due_date" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">説明</label>
<textarea class="form-control" id="description" name="description"
rows="3">{{.description}}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,43 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-journal-check display-4 text-primary"></i>
<h2 class="mt-2">ログイン</h2>
</div>
{{if .error}}
<div class="alert alert-danger">{{.error}}</div>
{{end}}
<form method="POST" action="/login">
{{.csrfField}}
<div class="mb-3">
<label for="email" class="form-label">メールアドレス</label>
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required
autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">パスワード</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
</div>
</form>
<hr class="my-4">
<div class="text-center">
<p class="mb-0">アカウントをお持ちでない方は</p>
<a href="/register" class="btn btn-outline-secondary mt-2">新規登録</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,54 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-person-plus display-4 text-primary"></i>
<h2 class="mt-2">新規登録</h2>
</div>
{{if .error}}
<div class="alert alert-danger">{{.error}}</div>
{{end}}
<form method="POST" action="/register">
{{.csrfField}}
<div class="mb-3">
<label for="name" class="form-label">名前</label>
<input type="text" class="form-control" id="name" name="name" value="{{.name}}" required
autofocus>
</div>
<div class="mb-3">
<label for="email" class="form-label">メールアドレス</label>
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">パスワード</label>
<input type="password" class="form-control" id="password" name="password" required
minlength="6">
<div class="form-text">6文字以上</div>
</div>
<div class="mb-3">
<label for="password_confirm" class="form-label">パスワード(確認)</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
required minlength="6">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">登録</button>
</div>
</form>
<hr class="my-4">
<div class="text-center">
<p class="mb-0">既にアカウントをお持ちの方は</p>
<a href="/login" class="btn btn-outline-secondary mt-2">ログイン</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,105 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.title}} - Super Homework Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet">
<style>
.navbar-dark .navbar-nav .nav-link,
.navbar-brand {
color: #fff !important;
transition: color 0.15s ease-in-out;
}
.navbar-dark .navbar-nav .nav-link:hover,
.navbar-brand:hover {
color: rgba(255, 255, 255, 0.75) !important;
}
</style>
{{template "head" .}}
</head>
<body>
{{if .userName}}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-journal-check me-2"></i>Super Homework Manager
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-house-door me-1"></i>ダッシュボード</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/assignments"><i class="bi bi-list-task me-1"></i>課題一覧</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/assignments/new"><i class="bi bi-plus-circle me-1"></i>課題登録</a>
</li>
{{if .isAdmin}}
<li class="nav-item">
<a class="nav-link" href="/admin/users"><i class="bi bi-people me-1"></i>ユーザー管理</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/api-keys"><i class="bi bi-key me-1"></i>APIキー管理</a>
</li>
{{end}}
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i>{{.userName}}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/profile"><i class="bi bi-person me-2"></i>プロフィール</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<form action="/logout" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" class="dropdown-item"><i
class="bi bi-box-arrow-right me-2"></i>ログアウト</button>
</form>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{{end}}
<main class="container py-2">
{{template "content" .}}
</main>
<footer class="footer mt-auto py-1 bg-light">
<div class="container text-center">
<span class="text-muted small" style="font-size: 0.75rem;">Super Homework Manager</span><br>
<small class="text-muted" style="font-size: 0.65rem;">Licensed under <a
href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a> | Time:
{{.processing_time}}</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
{{template "scripts" .}}
</body>
</html>
{{end}}
{{define "head"}}{{end}}
{{define "scripts"}}{{end}}

View File

@@ -0,0 +1,300 @@
{{template "base" .}}
{{define "head"}}
<style>
@keyframes pulse-bg {
0%,
100% {
background-color: #fff3cd;
}
50% {
background-color: #ffe69c;
}
}
@keyframes pulse-bg-danger {
0%,
100% {
background-color: #f8d7da;
}
50% {
background-color: #f5c2c7;
}
}
@keyframes blink-banner {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.anxiety-warning {
animation: pulse-bg 2s infinite;
}
.anxiety-danger {
animation: pulse-bg-danger 1s infinite;
}
.urgent-banner {
z-index: 1030;
animation: blink-banner 1s infinite;
}
.urgent-banner-danger {
background: linear-gradient(90deg, #dc3545, #c82333);
color: white;
}
.urgent-banner-warning {
background: linear-gradient(90deg, #fd7e14, #e06c00);
color: white;
}
.urgent-countdown {
font-size: 1.5rem;
font-weight: bold;
}
</style>
{{end}}
{{define "content"}}
<div id="urgentBanner" class="urgent-banner py-3 text-center d-none">
<div class="container">
<i class="bi bi-exclamation-octagon-fill me-2"></i>
<span id="urgentMessage"></span>
<div class="urgent-countdown mt-1">
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span>
</div>
</div>
</div>
<h1 class="mb-4"><i class="bi bi-house-door me-2"></i>ダッシュボード</h1>
<div class="row g-4 mb-4">
<div class="col-6 col-md-3">
<div class="card bg-primary text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-white-50">未完了の課題</h6>
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
</div>
<i class="bi bi-list-task display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-warning text-dark h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-dark-50">今日が期限</h6>
<h2 class="mb-0">{{.stats.DueToday}}</h2>
</div>
<i class="bi bi-calendar-event display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-info text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-white-50">今週が期限</h6>
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
</div>
<i class="bi bi-calendar-week display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-danger text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-white-50">期限切れ</h6>
<h2 class="mb-0">{{.stats.Overdue}}</h2>
</div>
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
{{if .overdue}}
<div class="col-lg-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white"><i class="bi bi-exclamation-triangle me-2"></i>期限切れの課題</div>
<ul class="list-group list-group-flush">
{{range .overdue}}
<li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div>
<strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
</div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
{{if .dueToday}}
<div class="col-lg-6">
<div class="card border-warning">
<div class="card-header bg-warning text-dark"><i class="bi bi-calendar-event me-2"></i>今日が期限</div>
<ul class="list-group list-group-flush">
{{range .dueToday}}
<li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div>
<strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
</div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
{{if .upcoming}}
<div class="col-lg-6">
<div class="card border-info">
<div class="card-header bg-info text-white"><i class="bi bi-calendar-week me-2"></i>今週の課題</div>
<ul class="list-group list-group-flush">
{{range .upcoming}}
<li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div>
<strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
</div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
</div>
{{if and (not .overdue) (not .dueToday) (not .upcoming)}}
<div class="text-center py-5">
<i class="bi bi-emoji-smile display-1 text-success"></i>
<h3 class="mt-3">今週の課題はありません!</h3>
<p class="text-muted">新しい課題を登録しましょう</p>
<a href="/assignments/new" class="btn btn-primary"><i class="bi bi-plus-circle me-1"></i>課題を登録</a>
</div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
(function () {
var banner = document.getElementById('urgentBanner');
var message = document.getElementById('urgentMessage');
var countdown = document.getElementById('urgentCountdown');
var body = document.body;
var items = document.querySelectorAll('[data-priority="high"][data-due]');
var mostUrgent = null;
var mostUrgentDue = Infinity;
items.forEach(function (item) {
var due = parseInt(item.dataset.due);
var now = Date.now();
var diff = due - now;
if (diff > 0 && diff < mostUrgentDue) {
mostUrgentDue = diff;
var titleEl = item.querySelector('strong');
mostUrgent = { due: due, title: titleEl ? titleEl.textContent : '課題' };
}
});
var hasOverdueHigh = false;
var overdueItems = document.querySelectorAll('[data-priority="high"]');
overdueItems.forEach(function (item) {
var due = parseInt(item.dataset.due);
if (due && due < Date.now()) {
hasOverdueHigh = true;
}
});
if (hasOverdueHigh) {
banner.classList.remove('d-none');
banner.classList.add('urgent-banner-danger');
message.innerHTML = '🚨 <strong>期限切れの重要課題があります!</strong>';
countdown.textContent = '今すぐ対応してください!';
body.classList.add('anxiety-danger');
} else if (mostUrgent && mostUrgentDue < 24 * 60 * 60 * 1000) {
banner.classList.remove('d-none');
banner.classList.add('urgent-banner-danger');
message.innerHTML = '🚨 <strong>「' + mostUrgent.title + '」の期限が迫っています!</strong>';
body.classList.add('anxiety-danger');
updateCountdown();
setInterval(updateCountdown, 1000);
} else if (mostUrgent && mostUrgentDue < 3 * 24 * 60 * 60 * 1000) {
banner.classList.remove('d-none');
banner.classList.add('urgent-banner-warning');
message.innerHTML = '⚠️ <strong>「' + mostUrgent.title + '」の期限が近づいています</strong>';
body.classList.add('anxiety-warning');
updateCountdown();
setInterval(updateCountdown, 1000);
}
function updateCountdown() {
if (!mostUrgent) return;
var now = Date.now();
var diff = mostUrgent.due - now;
if (diff <= 0) {
countdown.textContent = '期限切れ!';
return;
}
var days = Math.floor(diff / 86400000);
var hours = Math.floor((diff % 86400000) / 3600000);
var mins = Math.floor((diff % 3600000) / 60000);
var secs = Math.floor((diff % 60000) / 1000);
var text = '';
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒';
else text = mins + '分 ' + secs + '秒';
countdown.textContent = text;
}
})();
</script>
{{end}}

View File

@@ -0,0 +1,10 @@
{{template "base" .}}
{{define "content"}}
<div class="text-center py-5">
<i class="bi bi-exclamation-triangle display-1 text-danger"></i>
<h1 class="mt-4">{{.title}}</h1>
<p class="lead text-muted">{{.message}}</p>
<a href="/" class="btn btn-primary mt-3"><i class="bi bi-house-door me-1"></i>ダッシュボードに戻る</a>
</div>
{{end}}

View File

@@ -0,0 +1,71 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-8">
<h1 class="mb-4"><i class="bi bi-person me-2"></i>プロフィール</h1>
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">アカウント情報</h5>
</div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
<form method="POST" action="/profile">
{{.csrfField}}
<div class="mb-3">
<label for="email" class="form-label">メールアドレス</label>
<input type="email" class="form-control" id="email" value="{{.user.Email}}" disabled>
</div>
<div class="mb-3">
<label for="name" class="form-label">名前</label>
<input type="text" class="form-control" id="name" name="name" value="{{.user.Name}}"
required>
</div>
<div class="mb-3">
<label class="form-label">ロール</label>
<input type="text" class="form-control"
value="{{if eq .user.Role `admin`}}管理者{{else}}ユーザー{{end}}" disabled>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">パスワード変更</h5>
</div>
<div class="card-body">
{{if .passwordError}}<div class="alert alert-danger">{{.passwordError}}</div>{{end}}
{{if .passwordSuccess}}<div class="alert alert-success">{{.passwordSuccess}}</div>{{end}}
<form method="POST" action="/profile/password">
{{.csrfField}}
<div class="mb-3">
<label for="old_password" class="form-label">現在のパスワード</label>
<input type="password" class="form-control" id="old_password" name="old_password"
required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">新しいパスワード</label>
<input type="password" class="form-control" id="new_password" name="new_password"
required minlength="6">
<div class="form-text">6文字以上</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">新しいパスワード(確認)</label>
<input type="password" class="form-control" id="confirm_password"
name="confirm_password" required>
</div>
<button type="submit" class="btn btn-warning"><i class="bi bi-key me-1"></i>パスワード変更</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}