Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fac3b8591 | |||
| bd600c24c9 | |||
| c7f4c40964 | |||
| b150b530b5 | |||
| bd7d4af978 | |||
| 19d65fec9d | |||
| 2fdcca35e6 | |||
| 098f636a65 | |||
| b2dd70cf27 | |||
| 5ef801aae1 | |||
| 2f061f8bcd | |||
| 21843a7b86 | |||
| 1029f29033 | |||
| 8ef7ded91f | |||
| aaaecbfab4 | |||
| 2a4fac79b1 | |||
| 1113477111 | |||
| 080bd1f8d7 | |||
| 45bf048c47 | |||
| fee5d7c846 |
12
Caddyfile
12
Caddyfile
@@ -1,16 +1,10 @@
|
||||
# Caddyfile - Reverse Proxy Configuration
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. example.com を実際のドメインに置き換えてください
|
||||
# 2. DNS の A レコードをこのサーバーの IP アドレスに向けてください
|
||||
# 3. docker compose up -d で起動すると、自動的に HTTPS 証明書が取得されます
|
||||
#
|
||||
# ローカル開発用の場合は、以下のように変更してください
|
||||
# :80 {
|
||||
# reverse_proxy app:8080
|
||||
# }
|
||||
# 本番環境で公開する場合は、以下の `:80` を実際のドメイン(例: example.com)に変更してください。
|
||||
# 変更して docker compose up -d を再実行すると、自動的にHTTPS証明書が取得されます。
|
||||
|
||||
example.com {
|
||||
:80 {
|
||||
reverse_proxy app:8080
|
||||
|
||||
# ログ設定
|
||||
|
||||
172
README.md
172
README.md
@@ -1,102 +1,112 @@
|
||||
# Homework Manager
|
||||
<div align="center">
|
||||
|
||||
シンプルな課題管理アプリケーションです。学生の課題管理を効率化するために設計されています。
|
||||
# Super Homework Manager
|
||||
|
||||
シンプルで高機能な課題管理アプリケーション
|
||||
|
||||
[](https://go.dev/)
|
||||
[](LICENSE.md)
|
||||
[](#docker-での実行)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
学生の課題管理を効率化するために設計されたWebアプリケーションです。
|
||||
繰り返し課題の自動生成やダッシュボードによる期限管理など、日々の課題管理をサポートします。
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
| ダッシュボード | 課題一覧 | API |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
|
||||
## 特徴
|
||||
|
||||
- **課題管理**: 課題の登録、編集、削除、完了状況の管理
|
||||
- **繰り返し課題**: 日次・週次・月次の繰り返し課題を自動生成・管理
|
||||
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
|
||||
- **API対応**: 外部連携用のRESTful API (APIキー認証)
|
||||
- **セキュリティ**:
|
||||
- CSRF対策
|
||||
- レート制限 (Rate Limiting)
|
||||
- セキュアなセッション管理
|
||||
- **ポータビリティ**: Pure Go SQLiteドライバー使用により、CGO不要でどこでも動作
|
||||
| 機能 | 説明 |
|
||||
|---|---|
|
||||
| **課題管理** | 課題の登録・編集・削除・完了状況の管理 |
|
||||
| **繰り返し課題** | 日次・週次・月次の繰り返し課題を自動生成 |
|
||||
| **ダッシュボード** | 期限切れ・本日期限・今週期限の課題をひと目で確認 |
|
||||
| **REST API** | 外部連携用のAPIキー認証付きRESTful API |
|
||||
| **セキュリティ** | CSRF対策 / レート制限 / セキュアなセッション管理 / 2FA対応 |
|
||||
| **ポータビリティ** | Pure Go SQLiteドライバー使用でCGO不要 |
|
||||
|
||||

|
||||

|
||||

|
||||
## クイックスタート
|
||||
|
||||
### 前提条件
|
||||
|
||||
## TODO
|
||||
- **Docker Desktop** または Docker / Docker Compose
|
||||
- (ローカルで直接ビルドする場合のみ)**Go 1.24 以上**
|
||||
|
||||
- 取り組み目安時間の登録
|
||||
- SNS連携(もしかしたらやるかも)
|
||||
### 最も簡単な起動方法
|
||||
|
||||
## ドキュメント
|
||||
初めて使う方には、**Dockerを使用した起動**をおすすめします。
|
||||
|
||||
詳細な仕様やAPIドキュメントは `docs/` ディレクトリを参照してください。
|
||||
|
||||
- [仕様書](docs/SPECIFICATION.md): 機能詳細、データモデル、設定項目
|
||||
- [APIドキュメント](docs/API.md): APIのエンドポイント、リクエスト/レスポンス形式
|
||||
|
||||
## 前提条件
|
||||
|
||||
- Go 1.24 以上
|
||||
|
||||
## インストール方法
|
||||
|
||||
1. **リポジトリのクローン**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd Homework-Manager
|
||||
```
|
||||
|
||||
2. **依存関係のダウンロード**
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
3. **アプリケーションのビルド**
|
||||
```bash
|
||||
go build -o homework-manager cmd/server/main.go
|
||||
```
|
||||
|
||||
4. **設定ファイルの準備**
|
||||
サンプル設定ファイルをコピーして、`config.ini` を作成します。
|
||||
|
||||
```bash
|
||||
cp config.ini.example config.ini
|
||||
```
|
||||
※ Windows (PowerShell): `Copy-Item config.ini.example config.ini`
|
||||
|
||||
**重要**: 本番環境で使用する場合は、必ず `[session] secret` と `[security] csrf_secret` を変更してください。
|
||||
|
||||
5. **アプリケーションの実行**
|
||||
```bash
|
||||
./homework-manager
|
||||
```
|
||||
※ Windows (PowerShell): `.\homework-manager.exe`
|
||||
|
||||
ブラウザで `http://localhost:8080` にアクセスしてください。
|
||||
|
||||
## Dockerでの実行
|
||||
|
||||
DockerおよびDocker Composeがインストールされている環境では、以下の手順で簡単に起動できます。
|
||||
|
||||
1. **設定ファイルの準備**
|
||||
```bash
|
||||
cp config.ini.example config.ini
|
||||
```
|
||||
※ 必須です。これを行わないとDockerがディレクトリとして作成してしまい起動に失敗します。
|
||||
|
||||
2. **コンテナの起動**
|
||||
1. このリポジトリをダウンロード(または `git clone`)し、フォルダを開きます。
|
||||
2. フォルダ内にある `config.ini.docker.example` というファイルをコピーし、**名前を `config.ini` に変更**します。
|
||||
(※ **必須**: この作業を忘れると起動エラーになります)
|
||||
3. ターミナル(またはコマンドプロンプトやPowerShell)でこのフォルダを開き、以下のコマンドを実行します:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
4. ブラウザを開き、**http://localhost** にアクセスしてください。
|
||||
|
||||
3. **アクセスの確認**
|
||||
ブラウザで `http://localhost:8080` にアクセスしてください。
|
||||
> **注意**: 本番環境(外部公開するサーバー上)で使用する場合は、`Caddyfile` の `:80` を実際のドメインに変更し、`config.ini` 内の `[session] secret` 等の安全な文字列への変更を必ず行ってください。
|
||||
|
||||
### ローカルビルド(開発者向け)
|
||||
|
||||
開発目的で直接実行する場合の手順です。
|
||||
|
||||
```bash
|
||||
# 1. リポジトリのクローン
|
||||
git clone <repository-url>
|
||||
cd Homework-Manager
|
||||
|
||||
# 2. 依存関係のダウンロード
|
||||
go mod download
|
||||
|
||||
# 3. ビルド
|
||||
go build -o homework-manager cmd/server/main.go
|
||||
|
||||
# 4. 設定ファイルの準備
|
||||
cp config.ini.example config.ini
|
||||
|
||||
# 5. 実行
|
||||
./homework-manager
|
||||
```
|
||||
|
||||
> **Windows (PowerShell)** の場合:
|
||||
> `Copy-Item config.ini.example config.ini` → `.\homework-manager.exe`
|
||||
|
||||
ブラウザで **http://localhost:8080** にアクセスしてください。
|
||||
|
||||
## 利用時の注意点
|
||||
|
||||
1人でSuper Homework Managerを利用する場合は、自分のユーザを登録した後にconfigファイルの[auth]セクションのallow_registrationをfalseに変更し再起動してください。
|
||||
|
||||
## 更新方法
|
||||
|
||||
1. `git pull` で最新コードを取得
|
||||
2. `go build -o homework-manager cmd/server/main.go` で再ビルド
|
||||
3. アプリケーションを再起動
|
||||
```bash
|
||||
git pull
|
||||
go build -o homework-manager cmd/server/main.go
|
||||
# アプリケーションを再起動
|
||||
```
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| ドキュメント | 内容 |
|
||||
|---|---|
|
||||
| [仕様書](docs/SPECIFICATION.md) | 機能詳細・データモデル・設定項目 |
|
||||
| [APIドキュメント](docs/API.md) | エンドポイント・リクエスト/レスポンス形式 |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] 取り組み目安時間の登録
|
||||
- [ ] SNS連携
|
||||
|
||||
## ライセンス
|
||||
|
||||
本ソフトウェアのライセンスはAGPLv3 (GNU Affero General Public License v3)です。
|
||||
詳しくはLICENSEファイルをご覧ください。
|
||||
[AGPLv3 (GNU Affero General Public License v3)](LICENSE.md)
|
||||
|
||||
@@ -3,35 +3,54 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/config"
|
||||
"homework-manager/internal/database"
|
||||
"homework-manager/internal/router"
|
||||
"homework-manager/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
configPath := flag.String("config", "", "Path to config.ini file (default: config.ini in current directory)")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg := config.Load(*configPath)
|
||||
|
||||
// Connect to database
|
||||
if cfg.Notification.TelegramBotToken != "" && cfg.Notification.TelegramWebhookSecret == "" {
|
||||
log.Fatal("telegram_bot_token is set but telegram_webhook_secret is not set — refusing to start with unauthenticated webhook endpoint")
|
||||
}
|
||||
|
||||
log.Printf("Connecting to database (driver: %s)", cfg.Database.Driver)
|
||||
if err := database.Connect(cfg.Database, cfg.Debug); err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := database.Migrate(); err != nil {
|
||||
log.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Setup router
|
||||
recurringService := service.NewRecurringAssignmentService()
|
||||
if err := recurringService.GenerateNextAssignments(); err != nil {
|
||||
log.Printf("recurring generation error on startup: %v", err)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("recurring scheduler panic: %v", r)
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := recurringService.GenerateNextAssignments(); err != nil {
|
||||
log.Printf("recurring generation error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
r := router.Setup(cfg)
|
||||
|
||||
// Start server
|
||||
log.Printf("Server starting on http://localhost:%s", cfg.Port)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
|
||||
@@ -22,7 +22,7 @@ secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
|
||||
allow_registration = true
|
||||
|
||||
[security]
|
||||
https = true
|
||||
https = false
|
||||
; こちらも本番環境では必ず変更してください
|
||||
csrf_secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
|
||||
rate_limit_enabled = true
|
||||
@@ -32,3 +32,16 @@ trusted_proxies = 172.16.0.0/12
|
||||
|
||||
[notification]
|
||||
telegram_bot_token =
|
||||
; telegram_webhook_secret = your-webhook-secret
|
||||
|
||||
[captcha]
|
||||
; CAPTCHAを有効にするか (true/false)
|
||||
enabled = false
|
||||
|
||||
; CAPTCHAの種類: "image"(自前生成)または "turnstile"(Cloudflare Turnstile)
|
||||
type = image
|
||||
|
||||
; Cloudflare Turnstileを使用する場合(typeをturnstileに設定)
|
||||
; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定
|
||||
; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx
|
||||
; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
@@ -58,3 +58,19 @@ rate_limit_window = 60
|
||||
; Telegram Bot Token (@BotFatherで取得)
|
||||
; ユーザーはプロフィール画面でChat IDを設定します
|
||||
telegram_bot_token =
|
||||
|
||||
; Telegram Webhook シークレットトークン(任意)
|
||||
; setWebhook の secret_token に指定した値と一致させる
|
||||
; telegram_webhook_secret = your-webhook-secret
|
||||
|
||||
[captcha]
|
||||
; CAPTCHAを有効にするか (true/false)
|
||||
enabled = false
|
||||
|
||||
; CAPTCHAの種類: "image"(自前生成)または "turnstile"(Cloudflare Turnstile)
|
||||
type = image
|
||||
|
||||
; Cloudflare Turnstileを使用する場合(typeをturnstileに設定)
|
||||
; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定
|
||||
; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx
|
||||
; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
@@ -9,8 +9,6 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
424
docs/API.md
424
docs/API.md
@@ -40,7 +40,12 @@ Authorization: Bearer hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
| メソッド | パス | 説明 |
|
||||
|----------|------|------|
|
||||
| GET | `/api/v1/assignments` | 課題一覧取得 |
|
||||
| GET | `/api/v1/assignments` | 課題一覧取得(フィルタ・ページネーション対応) |
|
||||
| GET | `/api/v1/assignments/pending` | 未完了課題一覧取得 |
|
||||
| GET | `/api/v1/assignments/completed` | 完了済み課題一覧取得 |
|
||||
| GET | `/api/v1/assignments/overdue` | 期限切れ課題一覧取得 |
|
||||
| GET | `/api/v1/assignments/due-today` | 本日期限の課題一覧取得 |
|
||||
| GET | `/api/v1/assignments/due-this-week` | 今週期限の課題一覧取得 |
|
||||
| GET | `/api/v1/assignments/:id` | 課題詳細取得 |
|
||||
| POST | `/api/v1/assignments` | 課題作成 |
|
||||
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
||||
@@ -64,7 +69,9 @@ GET /api/v1/assignments
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `filter` | string | フィルタ: `pending`, `completed`, `overdue` (省略時: 全件) |
|
||||
| `filter` | string | フィルタ: `pending`, `completed`, `overdue`(省略時: 全件) |
|
||||
| `page` | integer | ページ番号(デフォルト: `1`) |
|
||||
| `page_size` | integer | 1ページあたりの件数(デフォルト: `20`、最大: `100`) |
|
||||
|
||||
### レスポンス
|
||||
|
||||
@@ -79,13 +86,18 @@ GET /api/v1/assignments
|
||||
"title": "数学レポート",
|
||||
"description": "第5章の練習問題",
|
||||
"subject": "数学",
|
||||
"priority": "medium",
|
||||
"due_date": "2025-01-15T23:59:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T10:00:00+09:00",
|
||||
"updated_at": "2025-01-10T10:00:00+09:00"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
"count": 1,
|
||||
"total_count": 15,
|
||||
"total_pages": 1,
|
||||
"current_page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
@@ -95,11 +107,43 @@ GET /api/v1/assignments
|
||||
# 全件取得
|
||||
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments
|
||||
|
||||
# 未完了のみ取得
|
||||
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending
|
||||
# 未完了のみ(ページネーション付き)
|
||||
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/assignments?filter=pending&page=1&page_size=10"
|
||||
|
||||
# 期限切れのみ取得
|
||||
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue
|
||||
# 期限切れのみ
|
||||
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/assignments?filter=overdue"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 絞り込み済み課題一覧取得
|
||||
|
||||
専用エンドポイントでも同等の絞り込みができます(`pending` / `completed` / `overdue` はページネーション対応)。
|
||||
|
||||
```
|
||||
GET /api/v1/assignments/pending
|
||||
GET /api/v1/assignments/completed
|
||||
GET /api/v1/assignments/overdue
|
||||
GET /api/v1/assignments/due-today
|
||||
GET /api/v1/assignments/due-this-week
|
||||
```
|
||||
|
||||
### クエリパラメータ(pending / completed / overdue のみ)
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `page` | integer | ページ番号(デフォルト: `1`) |
|
||||
| `page_size` | integer | 1ページあたりの件数(デフォルト: `20`、最大: `100`) |
|
||||
|
||||
### レスポンス
|
||||
|
||||
`due-today` / `due-this-week` は `count` のみ返します(ページネーションなし)。その他は `GET /api/v1/assignments` と同形式です。
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/due-today
|
||||
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/due-this-week
|
||||
```
|
||||
|
||||
---
|
||||
@@ -127,6 +171,7 @@ GET /api/v1/assignments/:id
|
||||
"title": "数学レポート",
|
||||
"description": "第5章の練習問題",
|
||||
"subject": "数学",
|
||||
"priority": "medium",
|
||||
"due_date": "2025-01-15T23:59:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T10:00:00+09:00",
|
||||
@@ -163,29 +208,30 @@ POST /api/v1/assignments
|
||||
| `title` | string | ✅ | 課題タイトル |
|
||||
| `description` | string | | 説明 |
|
||||
| `subject` | string | | 教科・科目 |
|
||||
| `priority` | string | | 重要度: `low`, `medium`, `high`(デフォルト: `medium`) |
|
||||
| `due_date` | string | ✅ | 提出期限(RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD`) |
|
||||
| `reminder_enabled` | boolean | | リマインダーを有効にするか(省略時: false) |
|
||||
| `reminder_at` | string | | リマインダー設定時刻(形式はdue_dateと同じ) |
|
||||
| `urgent_reminder_enabled` | boolean | | 期限切れ時の督促リマインダーを有効にするか(省略時: true) |
|
||||
| `recurrence` | object | | 繰り返し設定(以下参照) |
|
||||
| `reminder_enabled` | boolean | | リマインダーを有効にするか(デフォルト: `false`) |
|
||||
| `reminder_at` | string | | リマインダー設定時刻(形式は `due_date` と同じ) |
|
||||
| `urgent_reminder_enabled` | boolean | | 督促リマインダーを有効にするか(デフォルト: `true`) |
|
||||
| `recurrence` | object | | 繰り返し設定(下記参照) |
|
||||
|
||||
### Recurrence オブジェクト
|
||||
|
||||
| フィールド | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `type` | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`, または空文字で無効) |
|
||||
| `interval` | integer | 間隔 (例: 1 = 毎週, 2 = 隔週) |
|
||||
| `weekday` | integer | 週次の曜日 (0=日, 1=月, ..., 6=土) |
|
||||
| `day` | integer | 月次の日付 (1-31) |
|
||||
| `type` | string | 繰り返しタイプ: `daily`, `weekly`, `monthly`(空文字で繰り返しなし) |
|
||||
| `interval` | integer | 繰り返し間隔(例: `1` = 毎週、`2` = 隔週) |
|
||||
| `weekday` | integer | 週次の曜日(`0`=日, `1`=月, ..., `6`=土) |
|
||||
| `day` | integer | 月次の日付(1-31) |
|
||||
| `until` | object | 終了条件 |
|
||||
|
||||
#### Recurrence.Until オブジェクト
|
||||
|
||||
| フィールド | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `type` | string | 終了タイプ (`never`, `count`, `date`) |
|
||||
| `count` | integer | 回数指定時の終了回数 |
|
||||
| `date` | string | 日付指定時の終了日 |
|
||||
| `type` | string | 終了タイプ: `never`, `count`, `date` |
|
||||
| `count` | integer | 終了回数(`count` 指定時) |
|
||||
| `date` | string | 終了日(`date` 指定時) |
|
||||
|
||||
### リクエスト例
|
||||
|
||||
@@ -194,6 +240,7 @@ POST /api/v1/assignments
|
||||
"title": "英語エッセイ",
|
||||
"description": "テーマ自由、1000語以上",
|
||||
"subject": "英語",
|
||||
"priority": "high",
|
||||
"due_date": "2025-01-20T17:00"
|
||||
}
|
||||
```
|
||||
@@ -209,6 +256,7 @@ POST /api/v1/assignments
|
||||
"title": "英語エッセイ",
|
||||
"description": "テーマ自由、1000語以上",
|
||||
"subject": "英語",
|
||||
"priority": "high",
|
||||
"due_date": "2025-01-20T17:00:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T11:00:00+09:00",
|
||||
@@ -216,11 +264,20 @@ POST /api/v1/assignments
|
||||
}
|
||||
```
|
||||
|
||||
繰り返し設定を含む場合は `recurring_assignment` を返します:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Recurring assignment created",
|
||||
"recurring_assignment": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**400 Bad Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Invalid input: title and due_date are required"
|
||||
"error": "Invalid input: Key: 'title' Error:Field validation for 'title' failed on the 'required' tag"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -230,7 +287,7 @@ POST /api/v1/assignments
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer hm_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \
|
||||
-d '{"title":"英語エッセイ","subject":"英語","due_date":"2025-01-20"}' \
|
||||
http://localhost:8080/api/v1/assignments
|
||||
```
|
||||
|
||||
@@ -257,6 +314,7 @@ PUT /api/v1/assignments/:id
|
||||
| `title` | string | 課題タイトル |
|
||||
| `description` | string | 説明 |
|
||||
| `subject` | string | 教科・科目 |
|
||||
| `priority` | string | 重要度: `low`, `medium`, `high` |
|
||||
| `due_date` | string | 提出期限 |
|
||||
| `reminder_enabled` | boolean | リマインダー有効/無効 |
|
||||
| `reminder_at` | string | リマインダー時刻 |
|
||||
@@ -273,21 +331,7 @@ PUT /api/v1/assignments/:id
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 2,
|
||||
"user_id": 1,
|
||||
"title": "英語エッセイ(修正版)",
|
||||
"description": "テーマ自由、1000語以上",
|
||||
"subject": "英語",
|
||||
"due_date": "2025-01-25T17:00:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T11:00:00+09:00",
|
||||
"updated_at": "2025-01-10T12:00:00+09:00"
|
||||
}
|
||||
```
|
||||
**200 OK** — 更新後の課題オブジェクト
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
@@ -332,25 +376,33 @@ DELETE /api/v1/assignments/:id
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Assignment deleted"
|
||||
}
|
||||
{ "message": "Assignment deleted" }
|
||||
```
|
||||
|
||||
繰り返し設定も削除した場合:
|
||||
|
||||
```json
|
||||
{ "message": "Assignment and recurring settings deleted" }
|
||||
```
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Assignment not found"
|
||||
}
|
||||
{ "error": "Assignment not found" }
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
# 課題のみ削除
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer hm_xxx" \
|
||||
http://localhost:8080/api/v1/assignments/2
|
||||
|
||||
# 課題と繰り返し設定をまとめて削除
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer hm_xxx" \
|
||||
"http://localhost:8080/api/v1/assignments/2?delete_recurring=true"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -380,6 +432,7 @@ PATCH /api/v1/assignments/:id/toggle
|
||||
"title": "数学レポート",
|
||||
"description": "第5章の練習問題",
|
||||
"subject": "数学",
|
||||
"priority": "medium",
|
||||
"due_date": "2025-01-15T23:59:00+09:00",
|
||||
"is_completed": true,
|
||||
"completed_at": "2025-01-12T14:30:00+09:00",
|
||||
@@ -391,9 +444,7 @@ PATCH /api/v1/assignments/:id/toggle
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Assignment not found"
|
||||
}
|
||||
{ "error": "Assignment not found" }
|
||||
```
|
||||
|
||||
### 例
|
||||
@@ -408,7 +459,7 @@ curl -X PATCH \
|
||||
|
||||
## 統計情報取得
|
||||
|
||||
ユーザーの課題統計を取得します。科目、日付範囲でフィルタリング可能です。
|
||||
ユーザーの課題統計を取得します。
|
||||
|
||||
```
|
||||
GET /api/v1/statistics
|
||||
@@ -419,8 +470,9 @@ GET /api/v1/statistics
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `subject` | string | 科目で絞り込み(省略時: 全科目) |
|
||||
| `from` | string | 課題登録日の開始日(YYYY-MM-DD形式) |
|
||||
| `to` | string | 課題登録日の終了日(YYYY-MM-DD形式) |
|
||||
| `from` | string | 課題登録日の開始日(`YYYY-MM-DD`) |
|
||||
| `to` | string | 課題登録日の終了日(`YYYY-MM-DD`) |
|
||||
| `include_archived` | boolean | アーカイブ済み課題を含む(デフォルト: `false`) |
|
||||
|
||||
### レスポンス
|
||||
|
||||
@@ -446,36 +498,11 @@ GET /api/v1/statistics
|
||||
"pending": 2,
|
||||
"overdue": 1,
|
||||
"on_time_completion_rate": 91.7
|
||||
},
|
||||
{
|
||||
"subject": "英語",
|
||||
"total": 10,
|
||||
"completed": 8,
|
||||
"pending": 2,
|
||||
"overdue": 0,
|
||||
"on_time_completion_rate": 87.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 科目別統計 (特定科目のみ)
|
||||
|
||||
```json
|
||||
{
|
||||
"total_assignments": 15,
|
||||
"completed_assignments": 12,
|
||||
"pending_assignments": 2,
|
||||
"overdue_assignments": 1,
|
||||
"on_time_completion_rate": 91.7,
|
||||
"filter": {
|
||||
"subject": "数学",
|
||||
"from": null,
|
||||
"to": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
@@ -487,9 +514,175 @@ curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?
|
||||
|
||||
# 日付範囲で絞り込み
|
||||
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31"
|
||||
```
|
||||
|
||||
# 科目と日付範囲の組み合わせ
|
||||
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31"
|
||||
---
|
||||
|
||||
## 繰り返し設定一覧取得
|
||||
|
||||
```
|
||||
GET /api/v1/recurring
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"recurring_assignments": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"title": "週次ミーティング",
|
||||
"subject": "その他",
|
||||
"priority": "medium",
|
||||
"recurrence_type": "weekly",
|
||||
"recurrence_interval": 1,
|
||||
"recurrence_weekday": 1,
|
||||
"due_time": "23:59",
|
||||
"end_type": "never",
|
||||
"is_active": true,
|
||||
"created_at": "2025-01-01T00:00:00+09:00",
|
||||
"updated_at": "2025-01-01T00:00:00+09:00"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定詳細取得
|
||||
|
||||
```
|
||||
GET /api/v1/recurring/:id
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 繰り返し設定ID |
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK** — 繰り返し設定オブジェクト(一覧と同形式)
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{ "error": "Recurring assignment not found" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定更新
|
||||
|
||||
```
|
||||
PUT /api/v1/recurring/:id
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 繰り返し設定ID |
|
||||
|
||||
### リクエストボディ
|
||||
|
||||
すべてのフィールドはオプションです。省略されたフィールドは既存の値を維持します。
|
||||
|
||||
| フィールド | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `title` | string | タイトル |
|
||||
| `description` | string | 説明 |
|
||||
| `subject` | string | 教科・科目 |
|
||||
| `priority` | string | 重要度: `low`, `medium`, `high` |
|
||||
| `recurrence_type` | string | 繰り返しタイプ: `daily`, `weekly`, `monthly` |
|
||||
| `recurrence_interval` | integer | 繰り返し間隔 |
|
||||
| `recurrence_weekday` | integer | 週次の曜日(0-6) |
|
||||
| `recurrence_day` | integer | 月次の日付(1-31) |
|
||||
| `due_time` | string | 締切時刻(`HH:MM`) |
|
||||
| `end_type` | string | 終了タイプ: `never`, `count`, `date` |
|
||||
| `end_count` | integer | 終了回数 |
|
||||
| `end_date` | string | 終了日(`YYYY-MM-DD`) |
|
||||
| `is_active` | boolean | `false` で停止、`true` で再開 |
|
||||
| `reminder_enabled` | boolean | リマインダー有効/無効 |
|
||||
| `reminder_offset` | integer | リマインダーのオフセット(分) |
|
||||
| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 |
|
||||
| `edit_behavior` | string | 編集範囲: `this_only`, `this_and_future`, `all`(デフォルト: `this_only`) |
|
||||
|
||||
### リクエスト例(一時停止)
|
||||
|
||||
```json
|
||||
{
|
||||
"is_active": false
|
||||
}
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK** — 更新後の繰り返し設定オブジェクト
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{ "error": "Recurring assignment not found" }
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
# 一時停止
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer hm_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_active": false}' \
|
||||
http://localhost:8080/api/v1/recurring/1
|
||||
|
||||
# タイトルと締切時刻を変更
|
||||
curl -X PUT \
|
||||
-H "Authorization: Bearer hm_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"更新済みタスク","due_time":"22:00"}' \
|
||||
http://localhost:8080/api/v1/recurring/1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定削除
|
||||
|
||||
```
|
||||
DELETE /api/v1/recurring/:id
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 繰り返し設定ID |
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{ "message": "Recurring assignment deleted" }
|
||||
```
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{ "error": "Recurring assignment not found or failed to delete" }
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer hm_xxx" \
|
||||
http://localhost:8080/api/v1/recurring/1
|
||||
```
|
||||
|
||||
---
|
||||
@@ -511,6 +704,7 @@ curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?
|
||||
| 400 Bad Request | リクエストの形式が不正 |
|
||||
| 401 Unauthorized | 認証エラー |
|
||||
| 404 Not Found | リソースが見つからない |
|
||||
| 429 Too Many Requests | レート制限超過 |
|
||||
| 500 Internal Server Error | サーバー内部エラー |
|
||||
|
||||
---
|
||||
@@ -521,7 +715,7 @@ APIは以下の日付形式を受け付けます(優先度順):
|
||||
|
||||
1. **RFC3339**: `2025-01-15T23:59:00+09:00`
|
||||
2. **日時形式**: `2025-01-15T23:59`
|
||||
3. **日付のみ**: `2025-01-15`(時刻は23:59に設定)
|
||||
3. **日付のみ**: `2025-01-15`(時刻は `23:59` に設定)
|
||||
|
||||
レスポンスの日付はすべてRFC3339形式で返されます。
|
||||
|
||||
@@ -542,83 +736,3 @@ APIは以下の日付形式を受け付けます(優先度順):
|
||||
```
|
||||
|
||||
設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定一覧取得
|
||||
|
||||
```
|
||||
GET /api/v1/recurring
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"recurring_assignments": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"title": "週次ミーティング",
|
||||
"recurrence_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday": 1,
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定詳細取得
|
||||
|
||||
```
|
||||
GET /api/v1/recurring/:id
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定更新
|
||||
|
||||
```
|
||||
PUT /api/v1/recurring/:id
|
||||
```
|
||||
|
||||
### リクエストボディ
|
||||
|
||||
各フィールドはオプション。省略時は更新なし。
|
||||
|
||||
| フィールド | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `title` | string | タイトル |
|
||||
| `is_active` | boolean | `false` で停止、`true` で再開 |
|
||||
| `recurrence_type` | string | `daily`, `weekly`, `monthly` |
|
||||
| ... | ... | その他の設定フィールド |
|
||||
|
||||
### リクエスト例(停止)
|
||||
|
||||
```json
|
||||
{
|
||||
"is_active": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 繰り返し設定削除
|
||||
|
||||
```
|
||||
DELETE /api/v1/recurring/:id
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
@@ -8,9 +8,9 @@ Super Homework Managerは、学生の課題管理を支援するWebアプリケ
|
||||
|
||||
| 項目 | 技術 |
|
||||
|------|------|
|
||||
| 言語 | Go |
|
||||
| 言語 | Go 1.24+ |
|
||||
| Webフレームワーク | Gin |
|
||||
| データベース | SQLite (GORM with Pure Go driver - glebarez/sqlite) |
|
||||
| データベース | SQLite / MySQL / PostgreSQL (GORM、Pure Go SQLiteドライバー使用) |
|
||||
| セッション管理 | gin-contrib/sessions (Cookie store) |
|
||||
| テンプレートエンジン | Go html/template |
|
||||
| コンテナ | Docker対応 |
|
||||
@@ -27,7 +27,8 @@ homework-manager/
|
||||
│ ├── middleware/ # ミドルウェア
|
||||
│ ├── models/ # データモデル
|
||||
│ ├── repository/ # データアクセス層
|
||||
│ └── service/ # ビジネスロジック
|
||||
│ ├── service/ # ビジネスロジック
|
||||
│ └── validation/ # 入力バリデーション
|
||||
├── web/
|
||||
│ ├── static/ # 静的ファイル (CSS, JS)
|
||||
│ └── templates/ # HTMLテンプレート
|
||||
@@ -50,6 +51,8 @@ homework-manager/
|
||||
| PasswordHash | string | パスワードハッシュ | Not Null |
|
||||
| Name | string | 表示名 | Not Null |
|
||||
| Role | string | 権限 (`user` or `admin`) | Default: `user` |
|
||||
| TOTPSecret | string | TOTP秘密鍵 | - |
|
||||
| TOTPEnabled | bool | 2FA有効フラグ | Default: false |
|
||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||
@@ -68,6 +71,7 @@ homework-manager/
|
||||
| Priority | string | 重要度 (`low`, `medium`, `high`) | Default: `medium` |
|
||||
| DueDate | time.Time | 提出期限 | Not Null |
|
||||
| IsCompleted | bool | 完了フラグ | Default: false |
|
||||
| IsPinned | bool | ピン留めフラグ | Default: false, Composite Index (user_id, is_pinned) |
|
||||
| IsArchived | bool | アーカイブフラグ | Default: false |
|
||||
| CompletedAt | *time.Time | 完了日時 | Nullable |
|
||||
| ReminderEnabled | bool | 1回リマインダー有効 | Default: false |
|
||||
@@ -115,8 +119,7 @@ homework-manager/
|
||||
| UserID | uint | ユーザーID | Unique, Not Null |
|
||||
| TelegramEnabled | bool | Telegram通知 | Default: false |
|
||||
| TelegramChatID | string | Telegram Chat ID | - |
|
||||
| LineEnabled | bool | LINE通知 | Default: false |
|
||||
| LineNotifyToken | string | LINE Notifyトークン | - |
|
||||
| NotifyOnCreate | bool | 課題追加時に通知 | Default: true |
|
||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||
@@ -146,6 +149,7 @@ REST API認証用のAPIキーを管理するモデル。
|
||||
- **パスワード要件**: 8文字以上
|
||||
- **パスワードハッシュ**: bcryptを使用
|
||||
- **CSRF対策**: 全フォームでのトークン検証
|
||||
- **2段階認証 (TOTP)**: プロフィール画面からGoogle Authenticator等で設定可能。有効化後はログイン時にワンタイムパスワードの入力が必要
|
||||
|
||||
### 3.2 API認証
|
||||
|
||||
@@ -159,6 +163,7 @@ REST API認証用のAPIキーを管理するモデル。
|
||||
|--------|------|
|
||||
| `user` | 自分の課題のCRUD操作、プロフィール管理 |
|
||||
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
|
||||
|
||||
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
|
||||
|
||||
---
|
||||
@@ -170,8 +175,9 @@ REST API認証用のAPIキーを管理するモデル。
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| 新規登録 | メールアドレス、パスワード、名前で登録 |
|
||||
| ログイン | メールアドレスとパスワードでログイン |
|
||||
| ログイン | メールアドレスとパスワードでログイン。2FA有効時は続けてTOTPコードを入力 |
|
||||
| ログアウト | セッションをクリアしてログアウト |
|
||||
| CAPTCHA | ログイン・登録フォームへのbot対策(画像認証またはCloudflare Turnstile) |
|
||||
|
||||
### 4.2 課題管理機能
|
||||
|
||||
@@ -207,7 +213,7 @@ REST API認証用のAPIキーを管理するモデル。
|
||||
| 項目 | 説明 |
|
||||
|------|------|
|
||||
| 設定 | 課題登録・編集画面で通知日時を指定 |
|
||||
| 送信 | 指定日時にTelegram/LINEで通知 |
|
||||
| 送信 | 指定日時にTelegramで通知 |
|
||||
|
||||
#### 4.4.2 督促通知
|
||||
|
||||
@@ -220,22 +226,91 @@ REST API認証用のAPIキーを管理するモデル。
|
||||
| 重要度「中」 | **30分**ごとに通知 |
|
||||
| 重要度「小」 | **60分**ごとに通知 |
|
||||
| 停止条件 | 課題の完了ボタンを押すまで継続 |
|
||||
| Inline keyboard | 督促通知にボタンを付与: ✅完了 / ⏰15分後 / 💬今やってる |
|
||||
|
||||
#### 4.4.3 通知チャンネル
|
||||
#### 4.4.3 Telegram Bot 双方向操作
|
||||
|
||||
Telegram Bot への直接メッセージで課題の登録・確認・完了が可能。
|
||||
|
||||
**エンドポイント**: `POST /api/telegram/webhook`
|
||||
|
||||
**コマンド一覧**:
|
||||
|
||||
| コマンド | 形式 | 説明 |
|
||||
|----------|------|------|
|
||||
| `/add` | `/add <タイトル> <日付> [時刻] [#科目] [!優先度]` | 課題を追加(登録後60秒以内は取り消しボタン付き) |
|
||||
| `/list` | `/list` | 未完了課題を期限昇順で最大5件表示(全件数も表示) |
|
||||
| `/done` | `/done <ID>` | 課題を完了にする(IDは/listの#番号) |
|
||||
| `/help` | `/help` | コマンド一覧と使い方を表示 |
|
||||
|
||||
**日付パターン**:
|
||||
|
||||
| 入力 | 解釈 |
|
||||
|------|------|
|
||||
| `今日` / `明日` / `明後日` | 当日・翌日・翌々日 |
|
||||
| `月` `火` `水` `木` `金` `土` `日` | 次回その曜日(当日なら当日) |
|
||||
| `1/15` / `1月15日` | 今年のその日(過去なら翌年) |
|
||||
|
||||
時刻形式: `23:59` / `17時` / `17時30分`(省略時: `23:59`)
|
||||
|
||||
**督促通知 inline keyboard ボタン動作**:
|
||||
|
||||
| ボタン | 動作 |
|
||||
|--------|------|
|
||||
| ✅ 完了 | 課題を完了済みにし、キーボードを削除 |
|
||||
| ⏰ 15分後 | 次回督促を15分後に延期 |
|
||||
| 💬 今やってる | 次回督促を1時間後に延期 |
|
||||
|
||||
**認証**: `X-Telegram-Bot-Api-Secret-Token` ヘッダーを `telegram_webhook_secret` と定数時間比較で検証する(必須・スキップ不可)。`telegram_bot_token` と `telegram_webhook_secret` の両方が設定されている場合のみ `POST /api/telegram/webhook` ルートが登録される。Telegramの個人チャットでは `chat.id` がユーザーIDと等しく秘匿性のない数値であるため、secretを必須化しないとchat_idを知る/推測する第三者が他人になりすましてコマンドを実行できてしまう。
|
||||
|
||||
**セットアップ手順** (初回のみ):
|
||||
|
||||
1. **Bot作成**: Telegramで [@BotFather](https://t.me/BotFather) に `/newbot` を送り、Bot Token (`123456:ABC-...`) を取得します。
|
||||
|
||||
2. **config.ini に設定**:
|
||||
```ini
|
||||
[notification]
|
||||
telegram_bot_token = 123456:ABC-...
|
||||
telegram_webhook_secret = 任意のランダム文字列
|
||||
```
|
||||
|
||||
3. **Webhook登録** (サーバー起動後、一度だけ実行):
|
||||
```
|
||||
https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://your-domain/api/telegram/webhook&secret_token=<SECRET>
|
||||
```
|
||||
`<TOKEN>` と `<SECRET>` は上記 config.ini の値と一致させてください。
|
||||
|
||||
4. **ユーザー側のChat ID登録**: ユーザーはBotに任意のメッセージを送り、アプリの **プロフィール画面 → 通知設定** で自分のChat IDを入力して有効化します。Chat IDの確認方法: [@userinfobot](https://t.me/userinfobot) に `/start` を送ると表示されます。
|
||||
|
||||
> **必須**: `telegram_bot_token` を設定する場合は `telegram_webhook_secret` も必ず設定してください。未設定のまま `telegram_bot_token` だけを設定して起動しようとすると、安全のため起動に失敗します(Webhookルート自体も登録されません)。
|
||||
|
||||
#### 4.4.4 通知チャンネル
|
||||
|
||||
| チャンネル | 設定方法 |
|
||||
|------------|----------|
|
||||
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
|
||||
| LINE Notify | プロフィールでアクセストークン入力 |
|
||||
|
||||
### 4.5 プロフィール機能
|
||||
### 4.5 CSVエクスポート機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| CSVダウンロード | 統計ページから課題一覧をCSVファイルとしてダウンロード (`GET /assignments/export`) |
|
||||
| 期間フィルタ | 提出期限(`due_date`)を基準に開始日・終了日で絞り込み可能 |
|
||||
| 科目フィルタ | 特定の科目のみを対象にエクスポート可能 |
|
||||
| 全件エクスポート | 期間・科目を未指定の場合は全課題を出力 |
|
||||
| CSVカラム | ID, タイトル, 科目, 説明, 重要度, 提出期限, 完了状態, 完了日時, 登録日時 |
|
||||
| エンコーディング | UTF-8 BOM付き(Excel直接開き対応) |
|
||||
|
||||
### 4.6 プロフィール機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| プロフィール表示 | ユーザー情報を表示 |
|
||||
| プロフィール更新 | 表示名を変更 |
|
||||
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
|
||||
| 通知設定 | Telegram/LINE通知の有効化とトークン設定 |
|
||||
| 通知設定 | Telegram通知の有効化とChat ID設定 |
|
||||
| 2FA設定 | TOTPアプリ(Google Authenticator等)でQRコードをスキャンし2FAを有効化 |
|
||||
| 2FA無効化 | 有効中の2FAを無効化 |
|
||||
|
||||
### 4.6 管理者機能
|
||||
|
||||
@@ -279,7 +354,14 @@ rate_limit_requests = 100
|
||||
rate_limit_window = 60
|
||||
|
||||
[notification]
|
||||
telegram_bot_token = your-telegram-bot-token
|
||||
telegram_bot_token = your-telegram-bot-token
|
||||
|
||||
[captcha]
|
||||
enabled = false
|
||||
type = image
|
||||
# type = turnstile の場合は以下も設定
|
||||
# turnstile_site_key = your-site-key
|
||||
# turnstile_secret_key = your-secret-key
|
||||
```
|
||||
|
||||
### 5.2 設定項目
|
||||
@@ -288,16 +370,27 @@ rate_limit_window = 60
|
||||
|------------|------|------|--------------|
|
||||
| `server` | `port` | サーバーポート | `8080` |
|
||||
| `server` | `debug` | デバッグモード | `true` |
|
||||
| `database` | `driver` | DBドライバー (sqlite, mysql, postgres) | `sqlite` |
|
||||
| `database` | `driver` | DBドライバー (`sqlite`, `mysql`, `postgres`) | `sqlite` |
|
||||
| `database` | `path` | SQLiteファイルパス | `homework.db` |
|
||||
| `session` | `secret` | セッション暗号化キー | (必須) |
|
||||
| `database` | `host` | DBホスト (MySQL/PostgreSQL) | `localhost` |
|
||||
| `database` | `port` | DBポート (MySQL/PostgreSQL) | `3306` |
|
||||
| `database` | `user` | DBユーザー (MySQL/PostgreSQL) | `root` |
|
||||
| `database` | `password` | DBパスワード (MySQL/PostgreSQL) | - |
|
||||
| `database` | `name` | DB名 (MySQL/PostgreSQL) | `homework_manager` |
|
||||
| `session` | `secret` | セッション暗号化キー | **(必須)** |
|
||||
| `auth` | `allow_registration` | 新規登録許可 | `true` |
|
||||
| `security` | `https` | HTTPS設定(Secure Cookie) | `false` |
|
||||
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | (必須) |
|
||||
| `security` | `https` | HTTPS設定 (Secure Cookie) | `false` |
|
||||
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | **(必須)** |
|
||||
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
||||
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
||||
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
||||
| `security` | `trusted_proxies` | 信頼するプロキシ | - |
|
||||
| `notification` | `telegram_bot_token` | Telegram Bot Token | - |
|
||||
| `notification` | `telegram_webhook_secret` | Webhookシークレットトークン | - |
|
||||
| `captcha` | `enabled` | CAPTCHA有効化 | `false` |
|
||||
| `captcha` | `type` | CAPTCHAタイプ (`image` or `turnstile`) | `image` |
|
||||
| `captcha` | `turnstile_site_key` | Cloudflare Turnstile サイトキー | - |
|
||||
| `captcha` | `turnstile_secret_key` | Cloudflare Turnstile シークレットキー | - |
|
||||
|
||||
### 5.3 環境変数
|
||||
|
||||
@@ -308,13 +401,23 @@ rate_limit_window = 60
|
||||
| `PORT` | サーバーポート |
|
||||
| `DATABASE_DRIVER` | データベースドライバー |
|
||||
| `DATABASE_PATH` | SQLiteデータベースファイルパス |
|
||||
| `DATABASE_HOST` | DBホスト |
|
||||
| `DATABASE_PORT` | DBポート |
|
||||
| `DATABASE_USER` | DBユーザー |
|
||||
| `DATABASE_PASSWORD` | DBパスワード |
|
||||
| `DATABASE_NAME` | DB名 |
|
||||
| `SESSION_SECRET` | セッション暗号化キー |
|
||||
| `CSRF_SECRET` | CSRFトークン秘密鍵 |
|
||||
| `GIN_MODE` | `release` でリリースモード(debug=false) |
|
||||
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
|
||||
| `HTTPS` | HTTPSモード (true/false) |
|
||||
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
|
||||
| `GIN_MODE` | `release` でリリースモード |
|
||||
| `ALLOW_REGISTRATION` | 新規登録許可 (`true`/`false`) |
|
||||
| `HTTPS` | HTTPSモード (`true`/`false`) |
|
||||
| `TRUSTED_PROXIES` | 信頼するプロキシ |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Webhookシークレットトークン |
|
||||
| `CAPTCHA_ENABLED` | CAPTCHA有効化 (`true`/`false`) |
|
||||
| `CAPTCHA_TYPE` | CAPTCHAタイプ (`image`/`turnstile`) |
|
||||
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile サイトキー |
|
||||
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile シークレットキー |
|
||||
|
||||
### 5.4 設定の優先順位
|
||||
|
||||
@@ -329,13 +432,16 @@ rate_limit_window = 60
|
||||
### 6.1 実装済みセキュリティ機能
|
||||
|
||||
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
|
||||
- **2段階認証 (TOTP)**: RFC 6238準拠のTOTPによる2FA
|
||||
- **CAPTCHA**: ログイン・登録時のbot対策(画像認証またはCloudflare Turnstile)
|
||||
- **セッションセキュリティ**: HttpOnly Cookie
|
||||
- **入力バリデーション**: 各ハンドラで基本的な入力検証
|
||||
- **CSFR対策**: Double Submit Cookieパターンまたは同期トークンによるCSRF保護
|
||||
- **CSRF対策**: Double Submit Cookieパターンによる全フォーム保護
|
||||
- **レート制限**: IPベースのリクエスト制限によるDoS対策
|
||||
- **論理削除**: データの完全削除を防ぐソフトデリート
|
||||
- **権限チェック**: ミドルウェアによるロールベースアクセス制御
|
||||
- **Secure Cookie**: HTTPS設定時のSecure属性付与
|
||||
- **セキュリティヘッダー**: X-Content-Type-Options, X-Frame-Options等の設定
|
||||
|
||||
### 6.2 推奨される本番環境設定
|
||||
|
||||
@@ -343,6 +449,7 @@ rate_limit_window = 60
|
||||
- HTTPSを有効化し、`HTTPS=true` を設定
|
||||
- `GIN_MODE=release` を設定
|
||||
- 必要に応じて `TRUSTED_PROXIES` を設定
|
||||
- `CAPTCHA_ENABLED=true` を設定してbot対策を強化
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
go.mod
3
go.mod
@@ -17,10 +17,12 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dchest/captcha v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
@@ -49,6 +51,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
@@ -11,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
@@ -87,6 +91,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
|
||||
@@ -19,6 +19,14 @@ type DatabaseConfig struct {
|
||||
|
||||
type NotificationConfig struct {
|
||||
TelegramBotToken string
|
||||
TelegramWebhookSecret string
|
||||
}
|
||||
|
||||
type CaptchaConfig struct {
|
||||
Enabled bool
|
||||
Type string // "turnstile" or "image"
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -34,6 +42,7 @@ type Config struct {
|
||||
TrustedProxies []string
|
||||
Database DatabaseConfig
|
||||
Notification NotificationConfig
|
||||
Captcha CaptchaConfig
|
||||
}
|
||||
|
||||
func Load(configPath string) *Config {
|
||||
@@ -56,6 +65,10 @@ func Load(configPath string) *Config {
|
||||
Password: "",
|
||||
Name: "homework_manager",
|
||||
},
|
||||
Captcha: CaptchaConfig{
|
||||
Enabled: false,
|
||||
Type: "image",
|
||||
},
|
||||
}
|
||||
|
||||
if configPath == "" {
|
||||
@@ -134,6 +147,24 @@ func Load(configPath string) *Config {
|
||||
if section.HasKey("telegram_bot_token") {
|
||||
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
|
||||
}
|
||||
if section.HasKey("telegram_webhook_secret") {
|
||||
cfg.Notification.TelegramWebhookSecret = section.Key("telegram_webhook_secret").String()
|
||||
}
|
||||
|
||||
// Captcha section
|
||||
section = iniFile.Section("captcha")
|
||||
if section.HasKey("enabled") {
|
||||
cfg.Captcha.Enabled = section.Key("enabled").MustBool(false)
|
||||
}
|
||||
if section.HasKey("type") {
|
||||
cfg.Captcha.Type = section.Key("type").String()
|
||||
}
|
||||
if section.HasKey("turnstile_site_key") {
|
||||
cfg.Captcha.TurnstileSiteKey = section.Key("turnstile_site_key").String()
|
||||
}
|
||||
if section.HasKey("turnstile_secret_key") {
|
||||
cfg.Captcha.TurnstileSecretKey = section.Key("turnstile_secret_key").String()
|
||||
}
|
||||
} else {
|
||||
log.Println("config.ini not found, using environment variables or defaults")
|
||||
}
|
||||
@@ -183,6 +214,21 @@ func Load(configPath string) *Config {
|
||||
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
|
||||
cfg.Notification.TelegramBotToken = telegramToken
|
||||
}
|
||||
if webhookSecret := os.Getenv("TELEGRAM_WEBHOOK_SECRET"); webhookSecret != "" {
|
||||
cfg.Notification.TelegramWebhookSecret = webhookSecret
|
||||
}
|
||||
if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" {
|
||||
cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1"
|
||||
}
|
||||
if captchaType := os.Getenv("CAPTCHA_TYPE"); captchaType != "" {
|
||||
cfg.Captcha.Type = captchaType
|
||||
}
|
||||
if turnstileSiteKey := os.Getenv("TURNSTILE_SITE_KEY"); turnstileSiteKey != "" {
|
||||
cfg.Captcha.TurnstileSiteKey = turnstileSiteKey
|
||||
}
|
||||
if turnstileSecretKey := os.Getenv("TURNSTILE_SECRET_KEY"); turnstileSecretKey != "" {
|
||||
cfg.Captcha.TurnstileSecretKey = turnstileSecretKey
|
||||
}
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||
|
||||
@@ -76,13 +76,19 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
||||
}
|
||||
|
||||
func Migrate() error {
|
||||
return DB.AutoMigrate(
|
||||
if err := DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Assignment{},
|
||||
&models.RecurringAssignment{},
|
||||
&models.APIKey{},
|
||||
&models.UserNotificationSettings{},
|
||||
)
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DB.Model(&models.RecurringAssignment{}).
|
||||
Where("recurrence_type = ? AND generation_lead_days = 0", models.RecurrenceWeekly).
|
||||
Update("generation_lead_days", 7).Error
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
"homework-manager/internal/validation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -238,6 +239,7 @@ type CreateAssignmentInput struct {
|
||||
Subject string `json:"subject"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"due_date" binding:"required"`
|
||||
SoftDueDate string `json:"soft_due_date"`
|
||||
|
||||
ReminderEnabled bool `json:"reminder_enabled"`
|
||||
ReminderAt string `json:"reminder_at"`
|
||||
@@ -264,6 +266,11 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dueDate, err := parseDateString(input.DueDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
|
||||
@@ -346,7 +353,17 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, input.ReminderEnabled, reminderAt, urgentReminder)
|
||||
var softDueDate *time.Time
|
||||
if input.SoftDueDate != "" {
|
||||
parsedSoft, err := parseDateString(input.SoftDueDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid soft_due_date format"})
|
||||
return
|
||||
}
|
||||
softDueDate = &parsedSoft
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, softDueDate, input.ReminderEnabled, reminderAt, urgentReminder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
||||
return
|
||||
@@ -361,6 +378,7 @@ type UpdateAssignmentInput struct {
|
||||
Subject string `json:"subject"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"due_date"`
|
||||
SoftDueDate string `json:"soft_due_date"`
|
||||
ReminderEnabled *bool `json:"reminder_enabled"`
|
||||
ReminderAt string `json:"reminder_at"`
|
||||
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
|
||||
@@ -386,6 +404,11 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
title := input.Title
|
||||
if title == "" {
|
||||
title = existing.Title
|
||||
@@ -437,7 +460,17 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
||||
urgentReminderEnabled = *input.UrgentReminderEnabled
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
softDueDate := existing.SoftDueDate
|
||||
if input.SoftDueDate != "" {
|
||||
parsedSoft, err := parseDateString(input.SoftDueDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid soft_due_date format"})
|
||||
return
|
||||
}
|
||||
softDueDate = &parsedSoft
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
||||
return
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/service"
|
||||
"homework-manager/internal/validation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -32,6 +35,18 @@ func (h *AssignmentHandler) getUserID(c *gin.Context) uint {
|
||||
return userID.(uint)
|
||||
}
|
||||
|
||||
func safeReferer(c *gin.Context, defaultPath string) string {
|
||||
referer := c.Request.Referer()
|
||||
if referer == "" {
|
||||
return defaultPath
|
||||
}
|
||||
u, err := url.Parse(referer)
|
||||
if err != nil || u.Host != c.Request.Host {
|
||||
return defaultPath
|
||||
}
|
||||
return referer
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Dashboard(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
stats, _ := h.assignmentService.GetDashboardStats(userID)
|
||||
@@ -62,6 +77,8 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
}
|
||||
query := c.Query("q")
|
||||
priority := c.Query("priority")
|
||||
subject := c.Query("subject")
|
||||
sort := c.Query("sort")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
@@ -69,7 +86,7 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
}
|
||||
const pageSize = 10
|
||||
|
||||
result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, page, pageSize)
|
||||
result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, sort, subject, page, pageSize)
|
||||
|
||||
var assignments []models.Assignment
|
||||
var totalPages, currentPage int
|
||||
@@ -83,6 +100,9 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
currentPage = result.CurrentPage
|
||||
}
|
||||
|
||||
subjects, _ := h.assignmentService.GetSubjectsByUser(userID)
|
||||
tabCounts := h.assignmentService.GetTabCounts(userID)
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
@@ -92,6 +112,10 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
"filter": filter,
|
||||
"query": query,
|
||||
"priority": priority,
|
||||
"subject": subject,
|
||||
"sort": sort,
|
||||
"subjects": subjects,
|
||||
"tabCounts": tabCounts,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"currentPage": currentPage,
|
||||
@@ -103,10 +127,66 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) TogglePin(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
if _, err := h.assignmentService.TogglePin(userID, uint(id)); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, safeReferer(c, "/assignments"))
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) BulkComplete(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
rawIDs := c.PostFormArray("ids")
|
||||
var ids []uint
|
||||
for _, raw := range rawIDs {
|
||||
if v, err := strconv.ParseUint(raw, 10, 32); err == nil {
|
||||
ids = append(ids, uint(v))
|
||||
}
|
||||
}
|
||||
if err := h.assignmentService.BulkComplete(userID, ids); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, safeReferer(c, "/assignments"))
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) BulkDelete(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
rawIDs := c.PostFormArray("ids")
|
||||
var ids []uint
|
||||
for _, raw := range rawIDs {
|
||||
if v, err := strconv.ParseUint(raw, 10, 32); err == nil {
|
||||
ids = append(ids, uint(v))
|
||||
}
|
||||
}
|
||||
if c.PostForm("delete_recurring") == "true" {
|
||||
if recurringIDs, err := h.assignmentService.GetRecurringIDsByIDs(userID, ids); err == nil {
|
||||
for _, rid := range recurringIDs {
|
||||
h.recurringService.Delete(userID, rid, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h.assignmentService.BulkDelete(userID, ids); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, safeReferer(c, "/assignments"))
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) New(c *gin.Context) {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
now := time.Now()
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
defaultDue := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 0, 0, now.Location())
|
||||
defaultSoftDue := defaultDue.Add(-48 * time.Hour)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
@@ -114,6 +194,8 @@ func (h *AssignmentHandler) New(c *gin.Context) {
|
||||
"userName": name,
|
||||
"currentWeekday": int(now.Weekday()),
|
||||
"currentDay": now.Day(),
|
||||
"defaultDueDate": defaultDue.Format("2006-01-02T15:04"),
|
||||
"defaultSoftDueDate": defaultSoftDue.Format("2006-01-02T15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,6 +208,22 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"error": err.Error(),
|
||||
"formTitle": title,
|
||||
"description": description,
|
||||
"subject": subject,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||
reminderAtStr := c.PostForm("reminder_at")
|
||||
var reminderAt *time.Time
|
||||
@@ -157,6 +255,16 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
var softDueDate *time.Time
|
||||
if softDueDateStr := c.PostForm("soft_due_date"); softDueDateStr != "" {
|
||||
if parsed, err := time.ParseInLocation("2006-01-02T15:04", softDueDateStr, time.Local); err == nil {
|
||||
softDueDate = &parsed
|
||||
} else if parsed, err := time.ParseInLocation("2006-01-02", softDueDateStr, time.Local); err == nil {
|
||||
p := parsed.Add(23*time.Hour + 59*time.Minute)
|
||||
softDueDate = &p
|
||||
}
|
||||
}
|
||||
|
||||
recurrenceType := c.PostForm("recurrence_type")
|
||||
if recurrenceType != "" && recurrenceType != "none" {
|
||||
|
||||
@@ -200,6 +308,12 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
|
||||
dueTime := dueDate.Format("15:04")
|
||||
|
||||
generationLeadDays := 0
|
||||
if v, err := strconv.Atoi(c.PostForm("generation_lead_days")); err == nil && v >= 0 {
|
||||
generationLeadDays = v
|
||||
}
|
||||
generationLeadTime := c.PostForm("generation_lead_time")
|
||||
|
||||
recurringService := service.NewRecurringAssignmentService()
|
||||
input := service.CreateRecurringAssignmentInput{
|
||||
Title: title,
|
||||
@@ -216,6 +330,8 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
EndDate: endDate,
|
||||
ReminderEnabled: reminderEnabled,
|
||||
UrgentReminderEnabled: urgentReminderEnabled,
|
||||
GenerationLeadDays: generationLeadDays,
|
||||
GenerationLeadTime: generationLeadTime,
|
||||
FirstDueDate: dueDate,
|
||||
}
|
||||
|
||||
@@ -236,7 +352,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
assignment, err := h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
assignment, err := h.assignmentService.Create(userID, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
@@ -298,6 +414,11 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
|
||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||
reminderAtStr := c.PostForm("reminder_at")
|
||||
var reminderAt *time.Time
|
||||
@@ -318,7 +439,17 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
var softDueDate *time.Time
|
||||
if softDueDateStr := c.PostForm("soft_due_date"); softDueDateStr != "" {
|
||||
if parsed, err := time.ParseInLocation("2006-01-02T15:04", softDueDateStr, time.Local); err == nil {
|
||||
softDueDate = &parsed
|
||||
} else if parsed, err := time.ParseInLocation("2006-01-02", softDueDateStr, time.Local); err == nil {
|
||||
p := parsed.Add(23*time.Hour + 59*time.Minute)
|
||||
softDueDate = &p
|
||||
}
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
@@ -331,7 +462,10 @@ func (h *AssignmentHandler) Toggle(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
h.assignmentService.ToggleComplete(userID, uint(id))
|
||||
assignment, err := h.assignmentService.ToggleComplete(userID, uint(id))
|
||||
if err == nil && assignment.IsCompleted && assignment.RecurringAssignmentID != nil {
|
||||
h.recurringService.TriggerForRecurring(*assignment.RecurringAssignmentID)
|
||||
}
|
||||
|
||||
referer := c.Request.Referer()
|
||||
if referer == "" {
|
||||
@@ -437,6 +571,73 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/statistics?include_archived=true")
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) ExportCSV(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
|
||||
var from, to *time.Time
|
||||
if fromStr := c.Query("from"); fromStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", fromStr, time.Local); err == nil {
|
||||
from = &t
|
||||
}
|
||||
}
|
||||
if toStr := c.Query("to"); toStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", toStr, time.Local); err == nil {
|
||||
to = &t
|
||||
}
|
||||
}
|
||||
subject := c.Query("subject")
|
||||
|
||||
assignments, err := h.assignmentService.GetForExport(userID, from, to, subject)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "エクスポートに失敗しました")
|
||||
return
|
||||
}
|
||||
|
||||
filename := "assignments_" + time.Now().Format("20060102") + ".csv"
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
c.Writer.Write([]byte("\xef\xbb\xbf"))
|
||||
|
||||
headers := []string{"ID", "タイトル", "科目", "説明", "重要度", "提出期限", "自分の期限", "完了", "完了日時", "登録日時"}
|
||||
w.Write(headers)
|
||||
|
||||
priorityLabel := map[string]string{"low": "低", "medium": "中", "high": "高"}
|
||||
for _, a := range assignments {
|
||||
completed := "未完了"
|
||||
if a.IsCompleted {
|
||||
completed = "完了"
|
||||
}
|
||||
completedAt := ""
|
||||
if a.CompletedAt != nil {
|
||||
completedAt = a.CompletedAt.Format("2006/01/02 15:04")
|
||||
}
|
||||
softDueDateStr := ""
|
||||
if a.SoftDueDate != nil {
|
||||
softDueDateStr = a.SoftDueDate.Format("2006/01/02 15:04")
|
||||
}
|
||||
label := priorityLabel[a.Priority]
|
||||
if label == "" {
|
||||
label = a.Priority
|
||||
}
|
||||
w.Write([]string{
|
||||
strconv.FormatUint(uint64(a.ID), 10),
|
||||
a.Title,
|
||||
a.Subject,
|
||||
a.Description,
|
||||
label,
|
||||
a.DueDate.Format("2006/01/02 15:04"),
|
||||
softDueDateStr,
|
||||
completed,
|
||||
completedAt,
|
||||
a.CreatedAt.Format("2006/01/02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) StopRecurring(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
@@ -558,6 +759,12 @@ func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
generationLeadDays := 0
|
||||
if v, err := strconv.Atoi(c.PostForm("generation_lead_days")); err == nil && v >= 0 {
|
||||
generationLeadDays = v
|
||||
}
|
||||
generationLeadTime := c.PostForm("generation_lead_time")
|
||||
|
||||
input := service.UpdateRecurringInput{
|
||||
Title: &title,
|
||||
Description: &description,
|
||||
@@ -572,6 +779,8 @@ func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) {
|
||||
EndCount: endCount,
|
||||
EndDate: endDate,
|
||||
EditBehavior: editBehavior,
|
||||
GenerationLeadDays: &generationLeadDays,
|
||||
GenerationLeadTime: &generationLeadTime,
|
||||
}
|
||||
|
||||
_, err = h.recurringService.Update(userID, uint(id), input)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/config"
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
@@ -10,33 +11,96 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const twoFAPendingKey = "2fa_pending_user_id"
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
totpService *service.TOTPService
|
||||
captchaService *service.CaptchaService
|
||||
captchaCfg config.CaptchaConfig
|
||||
}
|
||||
|
||||
func NewAuthHandler() *AuthHandler {
|
||||
func NewAuthHandler(captchaCfg config.CaptchaConfig) *AuthHandler {
|
||||
captchaSvc := service.NewCaptchaService(captchaCfg.Type, captchaCfg.TurnstileSecretKey)
|
||||
return &AuthHandler{
|
||||
authService: service.NewAuthService(),
|
||||
totpService: service.NewTOTPService(),
|
||||
captchaService: captchaSvc,
|
||||
captchaCfg: captchaCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) captchaData() gin.H {
|
||||
data := gin.H{
|
||||
"captchaEnabled": h.captchaCfg.Enabled,
|
||||
"captchaType": h.captchaCfg.Type,
|
||||
}
|
||||
if h.captchaCfg.Enabled && h.captchaCfg.Type == "turnstile" {
|
||||
data["turnstileSiteKey"] = h.captchaCfg.TurnstileSiteKey
|
||||
}
|
||||
if h.captchaCfg.Enabled && h.captchaCfg.Type == "image" {
|
||||
data["captchaID"] = h.captchaService.NewImageCaptcha()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (h *AuthHandler) verifyCaptcha(c *gin.Context) bool {
|
||||
if !h.captchaCfg.Enabled {
|
||||
return true
|
||||
}
|
||||
switch h.captchaCfg.Type {
|
||||
case "turnstile":
|
||||
token := c.PostForm("cf-turnstile-response")
|
||||
ok, err := h.captchaService.VerifyTurnstile(token, c.ClientIP())
|
||||
return err == nil && ok
|
||||
case "image":
|
||||
id := c.PostForm("captcha_id")
|
||||
answer := c.PostForm("captcha_answer")
|
||||
return h.captchaService.VerifyImageCaptcha(id, answer)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||
"title": "ログイン",
|
||||
})
|
||||
data := gin.H{"title": "ログイン"}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "login.html", data)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
|
||||
renderLoginError := func(msg string) {
|
||||
data := gin.H{
|
||||
"title": "ログイン",
|
||||
"error": msg,
|
||||
"email": email,
|
||||
}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "login.html", data)
|
||||
}
|
||||
|
||||
if !h.verifyCaptcha(c) {
|
||||
renderLoginError("CAPTCHAの検証に失敗しました。もう一度お試しください")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.Login(email, password)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||
"title": "ログイン",
|
||||
"error": "メールアドレスまたはパスワードが正しくありません",
|
||||
"email": email,
|
||||
})
|
||||
renderLoginError("メールアドレスまたはパスワードが正しくありません")
|
||||
return
|
||||
}
|
||||
|
||||
if user.TOTPEnabled {
|
||||
session := sessions.Default(c)
|
||||
session.Set(twoFAPendingKey, user.ID)
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login/2fa")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,35 +113,91 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
func (h *AuthHandler) ShowLogin2FA(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
if session.Get(twoFAPendingKey) == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
|
||||
"title": "2段階認証",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login2FA(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
pendingID := session.Get(twoFAPendingKey)
|
||||
if pendingID == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
userID := pendingID.(uint)
|
||||
user, err := h.authService.GetUserByID(userID)
|
||||
if err != nil {
|
||||
session.Delete(twoFAPendingKey)
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.PostForm("totp_code")
|
||||
if !h.totpService.Validate(user.TOTPSecret, code) {
|
||||
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
|
||||
"title": "2段階認証",
|
||||
"error": "認証コードが正しくありません",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session.Delete(twoFAPendingKey)
|
||||
session.Set(middleware.UserIDKey, user.ID)
|
||||
session.Set(middleware.UserRoleKey, user.Role)
|
||||
session.Set(middleware.UserNameKey, user.Name)
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
||||
data := gin.H{"title": "新規登録"}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", data)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
passwordConfirm := c.PostForm("password_confirm")
|
||||
name := c.PostForm("name")
|
||||
|
||||
if password != passwordConfirm {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
renderRegisterError := func(msg string) {
|
||||
data := gin.H{
|
||||
"title": "新規登録",
|
||||
"error": "パスワードが一致しません",
|
||||
"error": msg,
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
for k, v := range h.captchaData() {
|
||||
data[k] = v
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", data)
|
||||
}
|
||||
|
||||
if !h.verifyCaptcha(c) {
|
||||
renderRegisterError("CAPTCHAの検証に失敗しました。もう一度お試しください")
|
||||
return
|
||||
}
|
||||
|
||||
if password != passwordConfirm {
|
||||
renderRegisterError("パスワードが一致しません")
|
||||
return
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": "パスワードは8文字以上で入力してください",
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
renderRegisterError("パスワードは8文字以上で入力してください")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,12 +207,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
if err == service.ErrEmailAlreadyExists {
|
||||
errorMsg = "このメールアドレスは既に使用されています"
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": errorMsg,
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
renderRegisterError(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,23 @@ import (
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
authService *service.AuthService
|
||||
totpService *service.TOTPService
|
||||
notificationService *service.NotificationService
|
||||
appName string
|
||||
}
|
||||
|
||||
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
||||
return &ProfileHandler{
|
||||
authService: service.NewAuthService(),
|
||||
totpService: service.NewTOTPService(),
|
||||
notificationService: notificationService,
|
||||
appName: "Super-HomeworkManager",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +148,6 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
settings := &models.UserNotificationSettings{
|
||||
TelegramEnabled: c.PostForm("telegram_enabled") == "on",
|
||||
TelegramChatID: c.PostForm("telegram_chat_id"),
|
||||
LineEnabled: c.PostForm("line_enabled") == "on",
|
||||
LineNotifyToken: c.PostForm("line_token"),
|
||||
NotifyOnCreate: c.PostForm("notify_on_create") == "on",
|
||||
}
|
||||
|
||||
@@ -173,3 +176,135 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
const totpPendingSecretKey = "totp_pending_secret"
|
||||
|
||||
func (h *ProfileHandler) ShowTOTPSetup(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
setupData, err := h.totpService.GenerateSecret(user.Email, h.appName)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"error": "シークレットの生成に失敗しました",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
session.Set(totpPendingSecretKey, setupData.Secret)
|
||||
session.Save()
|
||||
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"secret": setupData.Secret,
|
||||
"qrCode": setupData.QRCodeB64,
|
||||
"otpAuthURL": setupData.OTPAuthURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) EnableTOTP(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
session := sessions.Default(c)
|
||||
secret, ok := session.Get(totpPendingSecretKey).(string)
|
||||
if !ok || secret == "" {
|
||||
c.Redirect(http.StatusFound, "/profile/totp/setup")
|
||||
return
|
||||
}
|
||||
|
||||
renderSetupError := func(msg string) {
|
||||
data := gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"error": msg,
|
||||
"secret": secret,
|
||||
}
|
||||
if setupData, err := h.totpService.SetupDataFromSecret(secret, user.Email, h.appName); err == nil {
|
||||
data["qrCode"] = setupData.QRCodeB64
|
||||
data["otpAuthURL"] = setupData.OTPAuthURL
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", data)
|
||||
}
|
||||
|
||||
password := c.PostForm("password")
|
||||
if _, err := h.authService.Login(user.Email, password); err != nil {
|
||||
renderSetupError("パスワードが正しくありません")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.PostForm("totp_code")
|
||||
if !h.totpService.Validate(secret, code) {
|
||||
renderSetupError("認証コードが正しくありません。もう一度試してください")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.EnableTOTP(userID, secret); err != nil {
|
||||
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
|
||||
"title": "2段階認証の設定",
|
||||
"error": "2段階認証の有効化に失敗しました",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session.Delete(totpPendingSecretKey)
|
||||
session.Save()
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
user, _ = h.authService.GetUserByID(userID)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpSuccess": "2段階認証を有効化しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) DisableTOTP(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
|
||||
password := c.PostForm("password")
|
||||
if _, err := h.authService.Login(user.Email, password); err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpError": "パスワードが正しくありません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.DisableTOTP(userID); err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpError": "2段階認証の無効化に失敗しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ = h.authService.GetUserByID(userID)
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"totpSuccess": "2段階認証を無効化しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
57
internal/handler/telegram_handler.go
Normal file
57
internal/handler/telegram_handler.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TelegramHandler handles incoming Telegram webhook requests.
|
||||
type TelegramHandler struct {
|
||||
telegramService *service.TelegramService
|
||||
webhookSecret string
|
||||
}
|
||||
|
||||
// NewTelegramHandler creates a TelegramHandler.
|
||||
func NewTelegramHandler(telegramService *service.TelegramService, webhookSecret string) *TelegramHandler {
|
||||
return &TelegramHandler{
|
||||
telegramService: telegramService,
|
||||
webhookSecret: webhookSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook is the endpoint registered as the Telegram bot webhook.
|
||||
// POST /api/telegram/webhook
|
||||
func (h *TelegramHandler) Webhook(c *gin.Context) {
|
||||
if h.webhookSecret == "" {
|
||||
c.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
|
||||
if subtle.ConstantTimeCompare([]byte(secret), []byte(h.webhookSecret)) != 1 {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var update service.TelegramUpdate
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Process asynchronously so Telegram gets a 200 within its timeout window.
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("telegram HandleUpdate panic: %v", r)
|
||||
}
|
||||
}()
|
||||
h.telegramService.HandleUpdate(update)
|
||||
}()
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -101,12 +101,6 @@ func CSRF(config CSRFConfig) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
newToken, err := generateCSRFToken(config.Secret)
|
||||
if err == nil {
|
||||
session.Set(csrfTokenKey, newToken)
|
||||
session.Save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,20 +8,32 @@ import (
|
||||
|
||||
type SecurityConfig struct {
|
||||
HTTPS bool
|
||||
TurnstileEnabled bool
|
||||
}
|
||||
|
||||
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if config.HTTPS {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
|
||||
scriptSrc := "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
frameSrc := "'none'"
|
||||
connectSrc := "'self'"
|
||||
if config.TurnstileEnabled {
|
||||
scriptSrc += " https://challenges.cloudflare.com"
|
||||
frameSrc = "https://challenges.cloudflare.com"
|
||||
connectSrc += " https://challenges.cloudflare.com"
|
||||
}
|
||||
|
||||
csp := []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||
"script-src " + scriptSrc,
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||
"font-src 'self' https://cdn.jsdelivr.net",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"connect-src " + connectSrc,
|
||||
"frame-src " + frameSrc,
|
||||
"frame-ancestors 'none'",
|
||||
}
|
||||
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
||||
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
|
||||
type Assignment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
UserID uint `gorm:"not null;index;index:idx_user_pinned" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `gorm:"not null;default:medium" json:"priority"`
|
||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||
SoftDueDate *time.Time `json:"soft_due_date,omitempty"`
|
||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||
IsPinned bool `gorm:"default:false;index:idx_user_pinned" json:"is_pinned"`
|
||||
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
@@ -23,7 +25,6 @@ type Assignment struct {
|
||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
|
||||
|
||||
// Recurring assignment reference
|
||||
RecurringAssignmentID *uint `gorm:"index" json:"recurring_assignment_id,omitempty"`
|
||||
RecurringAssignment *RecurringAssignment `gorm:"foreignKey:RecurringAssignmentID" json:"-"`
|
||||
|
||||
@@ -34,6 +35,13 @@ type Assignment struct {
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Assignment) GetEffectiveSoftDueDate() time.Time {
|
||||
if a.SoftDueDate != nil {
|
||||
return *a.SoftDueDate
|
||||
}
|
||||
return a.DueDate.Add(-48 * time.Hour)
|
||||
}
|
||||
|
||||
func (a *Assignment) IsOverdue() bool {
|
||||
return !a.IsCompleted && time.Now().After(a.DueDate)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ type UserNotificationSettings struct {
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
|
||||
TelegramChatID string `json:"telegram_chat_id"`
|
||||
LineEnabled bool `gorm:"default:false" json:"line_enabled"`
|
||||
LineNotifyToken string `json:"-"`
|
||||
|
||||
NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -45,6 +45,8 @@ type RecurringAssignment struct {
|
||||
GeneratedCount int `gorm:"default:0" json:"generated_count"`
|
||||
EditBehavior string `gorm:"not null;default:this_only" json:"edit_behavior"`
|
||||
|
||||
GenerationLeadDays int `gorm:"default:0" json:"generation_lead_days"`
|
||||
GenerationLeadTime string `gorm:"default:''" json:"generation_lead_time"`
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
ReminderOffset *int `json:"reminder_offset,omitempty"`
|
||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||
|
||||
@@ -12,6 +12,8 @@ type User struct {
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
||||
TOTPSecret string `gorm:"size:100" json:"-"`
|
||||
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
@@ -21,6 +21,15 @@ func (r *AssignmentRepository) Create(assignment *models.Assignment) error {
|
||||
return r.db.Create(assignment).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindByRecurringAndDue(recurringID uint, dueDate time.Time) (*models.Assignment, error) {
|
||||
var a models.Assignment
|
||||
err := r.db.Where("recurring_assignment_id = ? AND due_date = ?", recurringID, dueDate).First(&a).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &a, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindByID(id uint) (*models.Assignment, error) {
|
||||
var assignment models.Assignment
|
||||
err := r.db.First(&assignment, id).Error
|
||||
@@ -195,6 +204,28 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountDueTodayByUserID(userID uint) (int64, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||
userID, false, startOfDay, endOfDay).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountDueThisWeekByUserID(userID uint) (int64, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
weekLater := startOfDay.AddDate(0, 0, 7)
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||
userID, false, startOfDay, weekLater).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
type StatisticsFilter struct {
|
||||
Subject string
|
||||
From *time.Time
|
||||
@@ -336,6 +367,22 @@ func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindForExport(userID uint, from, to *time.Time, subject string) ([]models.Assignment, error) {
|
||||
var assignments []models.Assignment
|
||||
q := r.db.Where("user_id = ?", userID)
|
||||
if from != nil {
|
||||
q = q.Where("due_date >= ?", *from)
|
||||
}
|
||||
if to != nil {
|
||||
q = q.Where("due_date < ?", to.AddDate(0, 0, 1))
|
||||
}
|
||||
if subject != "" {
|
||||
q = q.Where("subject = ?", subject)
|
||||
}
|
||||
err := q.Order("due_date ASC").Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
||||
var subjects []string
|
||||
query := r.db.Model(&models.Assignment{}).
|
||||
@@ -347,7 +394,7 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter, sort, subject string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||
var assignments []models.Assignment
|
||||
var totalCount int64
|
||||
|
||||
@@ -361,6 +408,10 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority
|
||||
dbQuery = dbQuery.Where("priority = ?", priority)
|
||||
}
|
||||
|
||||
if subject != "" {
|
||||
dbQuery = dbQuery.Where("subject = ?", subject)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||
@@ -385,11 +436,24 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var orderClause string
|
||||
switch sort {
|
||||
case "due_desc":
|
||||
orderClause = "is_pinned DESC, due_date DESC"
|
||||
case "priority":
|
||||
orderClause = "is_pinned DESC, CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END ASC, due_date ASC"
|
||||
case "created_desc":
|
||||
orderClause = "is_pinned DESC, created_at DESC"
|
||||
case "subject":
|
||||
orderClause = "is_pinned DESC, subject ASC, due_date ASC"
|
||||
default:
|
||||
if filter == "completed" {
|
||||
dbQuery = dbQuery.Order("completed_at DESC")
|
||||
orderClause = "is_pinned DESC, completed_at DESC"
|
||||
} else {
|
||||
dbQuery = dbQuery.Order("due_date ASC")
|
||||
orderClause = "is_pinned DESC, due_date ASC"
|
||||
}
|
||||
}
|
||||
dbQuery = dbQuery.Order(orderClause)
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -402,3 +466,48 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority
|
||||
err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error
|
||||
return assignments, totalCount, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) TogglePin(userID, assignmentID uint) (*models.Assignment, error) {
|
||||
var assignment models.Assignment
|
||||
if err := r.db.Where("id = ? AND user_id = ?", assignmentID, userID).First(&assignment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newPinned := !assignment.IsPinned
|
||||
if err := r.db.Model(&assignment).Update("is_pinned", newPinned).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignment.IsPinned = newPinned
|
||||
return &assignment, nil
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) BulkComplete(userID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
return r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND id IN ? AND is_completed = ?", userID, ids, false).
|
||||
Updates(map[string]interface{}{
|
||||
"is_completed": true,
|
||||
"completed_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) BulkDelete(userID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("user_id = ? AND id IN ?", userID, ids).
|
||||
Delete(&models.Assignment{}).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) GetRecurringIDsByIDs(userID uint, ids []uint) ([]uint, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var recurringIDs []uint
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND id IN ? AND recurring_assignment_id IS NOT NULL", userID, ids).
|
||||
Pluck("recurring_assignment_id", &recurringIDs).Error
|
||||
return recurringIDs, err
|
||||
}
|
||||
|
||||
@@ -76,7 +76,10 @@ func (r *RecurringAssignmentRepository) FindDueForGeneration() ([]models.Recurri
|
||||
}
|
||||
}
|
||||
|
||||
if shouldGenerate {
|
||||
if !shouldGenerate {
|
||||
rec.IsActive = false
|
||||
r.db.Save(&rec)
|
||||
} else {
|
||||
result = append(result, rec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -24,11 +25,27 @@ func getFuncMap() template.FuncMap {
|
||||
"formatDate": func(t time.Time) string {
|
||||
return t.Format("2006/01/02")
|
||||
},
|
||||
"formatDateTime": func(t time.Time) string {
|
||||
return t.Format("2006/01/02 15:04")
|
||||
"formatDateTime": func(t interface{}) string {
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
return v.Format("2006/01/02 15:04")
|
||||
case *time.Time:
|
||||
if v != nil {
|
||||
return v.Format("2006/01/02 15:04")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"formatDateInput": func(t time.Time) string {
|
||||
return t.Format("2006-01-02T15:04")
|
||||
"formatDateInput": func(t interface{}) string {
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
return v.Format("2006-01-02T15:04")
|
||||
case *time.Time:
|
||||
if v != nil {
|
||||
return v.Format("2006-01-02T15:04")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"isOverdue": func(t time.Time, completed bool) bool {
|
||||
return !completed && time.Now().After(t)
|
||||
@@ -176,6 +193,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
securityConfig := middleware.SecurityConfig{
|
||||
HTTPS: cfg.HTTPS,
|
||||
TurnstileEnabled: cfg.Captcha.Enabled && cfg.Captcha.Type == "turnstile",
|
||||
}
|
||||
r.Use(middleware.SecurityHeaders(securityConfig))
|
||||
r.Use(middleware.ForceHTTPS(securityConfig))
|
||||
@@ -196,13 +214,28 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
notificationService.StartReminderScheduler()
|
||||
|
||||
authHandler := handler.NewAuthHandler()
|
||||
authHandler := handler.NewAuthHandler(cfg.Captcha)
|
||||
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
profileHandler := handler.NewProfileHandler(notificationService)
|
||||
apiHandler := handler.NewAPIHandler()
|
||||
apiRecurringHandler := handler.NewAPIRecurringHandler()
|
||||
|
||||
if cfg.Notification.TelegramBotToken != "" && cfg.Notification.TelegramWebhookSecret != "" {
|
||||
telegramService := service.NewTelegramService(notificationService, service.NewAssignmentService())
|
||||
telegramHandler := handler.NewTelegramHandler(telegramService, cfg.Notification.TelegramWebhookSecret)
|
||||
r.POST("/api/telegram/webhook", telegramHandler.Webhook)
|
||||
}
|
||||
|
||||
r.GET("/captcha/:file", gin.WrapH(captcha.Server(captcha.StdWidth, captcha.StdHeight)))
|
||||
r.GET("/captcha-new", func(c *gin.Context) {
|
||||
id := captcha.New()
|
||||
c.String(http.StatusOK, id)
|
||||
})
|
||||
|
||||
r.GET("/login/2fa", csrfMiddleware, authHandler.ShowLogin2FA)
|
||||
r.POST("/login/2fa", csrfMiddleware, authHandler.Login2FA)
|
||||
|
||||
guest := r.Group("/")
|
||||
guest.Use(middleware.GuestOnly())
|
||||
guest.Use(csrfMiddleware)
|
||||
@@ -232,11 +265,15 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
auth.GET("/assignments", assignmentHandler.Index)
|
||||
auth.GET("/assignments/new", assignmentHandler.New)
|
||||
auth.POST("/assignments", assignmentHandler.Create)
|
||||
auth.POST("/assignments/bulk-complete", assignmentHandler.BulkComplete)
|
||||
auth.POST("/assignments/bulk-delete", assignmentHandler.BulkDelete)
|
||||
auth.GET("/assignments/:id/edit", assignmentHandler.Edit)
|
||||
auth.POST("/assignments/:id", assignmentHandler.Update)
|
||||
auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle)
|
||||
auth.POST("/assignments/:id/pin", assignmentHandler.TogglePin)
|
||||
auth.POST("/assignments/:id/delete", assignmentHandler.Delete)
|
||||
|
||||
auth.GET("/assignments/export", assignmentHandler.ExportCSV)
|
||||
auth.GET("/statistics", assignmentHandler.Statistics)
|
||||
auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject)
|
||||
auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject)
|
||||
@@ -252,6 +289,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
auth.POST("/profile", profileHandler.Update)
|
||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
|
||||
auth.GET("/profile/totp/setup", profileHandler.ShowTOTPSetup)
|
||||
auth.POST("/profile/totp/setup", profileHandler.EnableTOTP)
|
||||
auth.POST("/profile/totp/disable", profileHandler.DisableTOTP)
|
||||
|
||||
admin := auth.Group("/admin")
|
||||
admin.Use(middleware.AdminRequired())
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewAssignmentService() *AssignmentService {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time, softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
if priority == "" {
|
||||
priority = "medium"
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func (s *AssignmentService) Create(userID uint, title, description, subject, pri
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
DueDate: dueDate,
|
||||
SoftDueDate: softDueDate,
|
||||
IsCompleted: false,
|
||||
ReminderEnabled: reminderEnabled,
|
||||
ReminderAt: reminderAt,
|
||||
@@ -171,7 +172,7 @@ func (s *AssignmentService) GetOverdueByUserPaginated(userID uint, page, pageSiz
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter string, page, pageSize int) (*PaginatedResult, error) {
|
||||
func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter, sort, subject string, page, pageSize int) (*PaginatedResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize)
|
||||
assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, sort, subject, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,7 +196,23 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
func (s *AssignmentService) TogglePin(userID, assignmentID uint) (*models.Assignment, error) {
|
||||
return s.assignmentRepo.TogglePin(userID, assignmentID)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) BulkComplete(userID uint, ids []uint) error {
|
||||
return s.assignmentRepo.BulkComplete(userID, ids)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) BulkDelete(userID uint, ids []uint) error {
|
||||
return s.assignmentRepo.BulkDelete(userID, ids)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetRecurringIDsByIDs(userID uint, ids []uint) ([]uint, error) {
|
||||
return s.assignmentRepo.GetRecurringIDsByIDs(userID, ids)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
assignment, err := s.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -206,6 +223,7 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
|
||||
assignment.Subject = subject
|
||||
assignment.Priority = priority
|
||||
assignment.DueDate = dueDate
|
||||
assignment.SoftDueDate = softDueDate
|
||||
assignment.ReminderEnabled = reminderEnabled
|
||||
assignment.ReminderAt = reminderAt
|
||||
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
||||
@@ -378,6 +396,10 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetForExport(userID uint, from, to *time.Time, subject string) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindForExport(userID, from, to, subject)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error {
|
||||
return s.assignmentRepo.ArchiveBySubject(userID, subject)
|
||||
}
|
||||
@@ -392,3 +414,26 @@ func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived
|
||||
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||
return s.assignmentRepo.GetArchivedSubjects(userID)
|
||||
}
|
||||
|
||||
type TabCounts struct {
|
||||
Pending int64
|
||||
DueToday int64
|
||||
DueThisWeek int64
|
||||
Completed int64
|
||||
Overdue int64
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetTabCounts(userID uint) TabCounts {
|
||||
pending, _ := s.assignmentRepo.CountPendingByUserID(userID)
|
||||
dueToday, _ := s.assignmentRepo.CountDueTodayByUserID(userID)
|
||||
dueThisWeek, _ := s.assignmentRepo.CountDueThisWeekByUserID(userID)
|
||||
completed, _ := s.assignmentRepo.CountCompletedByUserID(userID)
|
||||
overdue, _ := s.assignmentRepo.CountOverdueByUserID(userID)
|
||||
return TabCounts{
|
||||
Pending: pending,
|
||||
DueToday: dueToday,
|
||||
DueThisWeek: dueThisWeek,
|
||||
Completed: completed,
|
||||
Overdue: overdue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,3 +104,23 @@ func (s *AuthService) UpdateProfile(userID uint, name string) error {
|
||||
user.Name = name
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) EnableTOTP(userID uint, secret string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
user.TOTPSecret = secret
|
||||
user.TOTPEnabled = true
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) DisableTOTP(userID uint) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
user.TOTPSecret = ""
|
||||
user.TOTPEnabled = false
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
75
internal/service/captcha_service.go
Normal file
75
internal/service/captcha_service.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
)
|
||||
|
||||
type CaptchaService struct {
|
||||
captchaType string
|
||||
turnstileSecretKey string
|
||||
}
|
||||
|
||||
func NewCaptchaService(captchaType, turnstileSecretKey string) *CaptchaService {
|
||||
return &CaptchaService{
|
||||
captchaType: captchaType,
|
||||
turnstileSecretKey: turnstileSecretKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptchaService) NewImageCaptcha() string {
|
||||
return captcha.New()
|
||||
}
|
||||
func (s *CaptchaService) VerifyImageCaptcha(id, answer string) bool {
|
||||
return captcha.VerifyString(id, answer)
|
||||
}
|
||||
|
||||
type turnstileResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []string `json:"error-codes"`
|
||||
}
|
||||
|
||||
func (s *CaptchaService) VerifyTurnstile(token, remoteIP string) (bool, error) {
|
||||
if token == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("secret", s.turnstileSecretKey)
|
||||
form.Set("response", token)
|
||||
if remoteIP != "" {
|
||||
form.Set("remoteip", remoteIP)
|
||||
}
|
||||
|
||||
resp, err := http.Post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
"application/x-www-form-urlencoded",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("turnstile request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading turnstile response: %w", err)
|
||||
}
|
||||
|
||||
var result turnstileResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false, fmt.Errorf("parsing turnstile response: %w", err)
|
||||
}
|
||||
|
||||
return result.Success, nil
|
||||
}
|
||||
|
||||
func (s *CaptchaService) Type() string {
|
||||
return s.captchaType
|
||||
}
|
||||
169
internal/service/date_parser.go
Normal file
169
internal/service/date_parser.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
reTimeHHMM = regexp.MustCompile(`^(\d{1,2}):(\d{2})$`)
|
||||
reTimeHHJMM = regexp.MustCompile(`^(\d{1,2})時(\d{1,2})分$`)
|
||||
reTimeHHJ = regexp.MustCompile(`^(\d{1,2})時$`)
|
||||
reDateMD = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})$`)
|
||||
reDateMJDJ = regexp.MustCompile(`^(\d{1,2})月(\d{1,2})日$`)
|
||||
)
|
||||
|
||||
var weekdayNames = map[string]time.Weekday{
|
||||
"月": time.Monday,
|
||||
"火": time.Tuesday,
|
||||
"水": time.Wednesday,
|
||||
"木": time.Thursday,
|
||||
"金": time.Friday,
|
||||
"土": time.Saturday,
|
||||
"日": time.Sunday,
|
||||
}
|
||||
|
||||
// ParseAddCommand parses the arguments after /add.
|
||||
// Format: <title> <date> [time] [#subject] [!priority]
|
||||
// Returns title, dueDate, subject, priority (Go string: "high"/"medium"/"low"), error.
|
||||
// On format error, err.Error() == "usage" means show usage hint.
|
||||
func ParseAddCommand(text string) (title string, dueDate time.Time, subject string, priority string, err error) {
|
||||
priority = "medium"
|
||||
tokens := strings.Fields(text)
|
||||
if len(tokens) == 0 {
|
||||
err = fmt.Errorf("usage")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract #subject and !priority modifiers
|
||||
var core []string
|
||||
for _, tok := range tokens {
|
||||
switch {
|
||||
case strings.HasPrefix(tok, "#") && len(tok) > 1:
|
||||
subject = tok[1:]
|
||||
case strings.HasPrefix(tok, "!") && len(tok) > 1:
|
||||
priority = parsePriorityJP(tok[1:])
|
||||
default:
|
||||
core = append(core, tok)
|
||||
}
|
||||
}
|
||||
|
||||
if len(core) < 2 {
|
||||
err = fmt.Errorf("usage")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract optional time token from end
|
||||
hour, minute := 23, 59
|
||||
if h, m, ok := parseTimeToken(core[len(core)-1]); ok {
|
||||
hour, minute = h, m
|
||||
core = core[:len(core)-1]
|
||||
}
|
||||
|
||||
if len(core) < 2 {
|
||||
err = fmt.Errorf("usage")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract date token from end
|
||||
dateTok := core[len(core)-1]
|
||||
parsedDate, ok := parseDateToken(dateTok, hour, minute)
|
||||
if !ok {
|
||||
err = fmt.Errorf("日付が読み取れませんでした: %q\n使える形式: 今日/明日/明後日/月〜日/MM/DD/M月D日", dateTok)
|
||||
return
|
||||
}
|
||||
dueDate = parsedDate
|
||||
core = core[:len(core)-1]
|
||||
|
||||
if len(core) == 0 {
|
||||
err = fmt.Errorf("usage")
|
||||
return
|
||||
}
|
||||
title = strings.Join(core, " ")
|
||||
return
|
||||
}
|
||||
|
||||
func parsePriorityJP(s string) string {
|
||||
switch s {
|
||||
case "高":
|
||||
return "high"
|
||||
case "低":
|
||||
return "low"
|
||||
default:
|
||||
return "medium"
|
||||
}
|
||||
}
|
||||
|
||||
func parseTimeToken(s string) (hour, minute int, ok bool) {
|
||||
if m := reTimeHHMM.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
if h <= 23 && min <= 59 {
|
||||
return h, min, true
|
||||
}
|
||||
}
|
||||
if m := reTimeHHJMM.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
if h <= 23 && min <= 59 {
|
||||
return h, min, true
|
||||
}
|
||||
}
|
||||
if m := reTimeHHJ.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
if h <= 23 {
|
||||
return h, 0, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseDateToken(s string, hour, minute int) (time.Time, bool) {
|
||||
now := time.Now()
|
||||
loc := now.Location()
|
||||
|
||||
switch s {
|
||||
case "今日":
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc), true
|
||||
case "明日":
|
||||
d := now.AddDate(0, 0, 1)
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), hour, minute, 0, 0, loc), true
|
||||
case "明後日":
|
||||
d := now.AddDate(0, 0, 2)
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), hour, minute, 0, 0, loc), true
|
||||
}
|
||||
|
||||
if wd, ok := weekdayNames[s]; ok {
|
||||
d := nextWeekdayFrom(now, wd)
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), hour, minute, 0, 0, loc), true
|
||||
}
|
||||
|
||||
var month, day int
|
||||
if m := reDateMD.FindStringSubmatch(s); m != nil {
|
||||
month, _ = strconv.Atoi(m[1])
|
||||
day, _ = strconv.Atoi(m[2])
|
||||
} else if m := reDateMJDJ.FindStringSubmatch(s); m != nil {
|
||||
month, _ = strconv.Atoi(m[1])
|
||||
day, _ = strconv.Atoi(m[2])
|
||||
} else {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t := time.Date(now.Year(), time.Month(month), day, hour, minute, 0, 0, loc)
|
||||
if t.Before(now) {
|
||||
t = t.AddDate(1, 0, 0)
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// nextWeekdayFrom returns the date of the next occurrence of wd from 'from'.
|
||||
func nextWeekdayFrom(from time.Time, wd time.Weekday) time.Time {
|
||||
days := (int(wd) - int(from.Weekday()) + 7) % 7
|
||||
return from.AddDate(0, 0, days)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -51,70 +50,90 @@ func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.U
|
||||
settings.ID = existing.ID
|
||||
return database.GetDB().Save(settings).Error
|
||||
}
|
||||
func (s *NotificationService) SendTelegramNotification(chatID, message string) error {
|
||||
// InlineKeyboardMarkup represents a Telegram inline keyboard attached to a message.
|
||||
type InlineKeyboardMarkup struct {
|
||||
InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
// InlineKeyboardButton is a single button in an inline keyboard row.
|
||||
type InlineKeyboardButton struct {
|
||||
Text string `json:"text"`
|
||||
CallbackData string `json:"callback_data"`
|
||||
}
|
||||
|
||||
func (s *NotificationService) sendTelegramRequest(endpoint string, payload interface{}) error {
|
||||
if s.telegramBotToken == "" {
|
||||
return fmt.Errorf("telegram bot token is not configured")
|
||||
}
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("telegram chat ID is empty")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken)
|
||||
|
||||
payload := map[string]string{
|
||||
"chat_id": chatID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", s.telegramBotToken, endpoint)
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
|
||||
return fmt.Errorf("telegram API %s returned status %d", endpoint, resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) SendLineNotification(token, message string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("LINE Notify token is empty")
|
||||
func (s *NotificationService) SendTelegramNotification(chatID, message string) error {
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("telegram chat ID is empty")
|
||||
}
|
||||
return s.SendMessageWithKeyboard(chatID, message, nil)
|
||||
}
|
||||
|
||||
apiURL := "https://notify-api.line.me/api/notify"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("message", message)
|
||||
|
||||
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
// SendMessageWithKeyboard sends a Telegram message with an optional inline keyboard.
|
||||
func (s *NotificationService) SendMessageWithKeyboard(chatID, text string, keyboard *InlineKeyboardMarkup) error {
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("telegram chat ID is empty")
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode)
|
||||
if keyboard != nil {
|
||||
payload["reply_markup"] = keyboard
|
||||
}
|
||||
return s.sendTelegramRequest("sendMessage", payload)
|
||||
}
|
||||
|
||||
return nil
|
||||
// AnswerCallbackQuery acknowledges a Telegram inline button press.
|
||||
func (s *NotificationService) AnswerCallbackQuery(callbackID, text string) error {
|
||||
payload := map[string]interface{}{
|
||||
"callback_query_id": callbackID,
|
||||
"show_alert": false,
|
||||
}
|
||||
if text != "" {
|
||||
payload["text"] = text
|
||||
}
|
||||
return s.sendTelegramRequest("answerCallbackQuery", payload)
|
||||
}
|
||||
|
||||
// EditMessageRemoveKeyboard removes the inline keyboard from an existing message.
|
||||
func (s *NotificationService) EditMessageRemoveKeyboard(chatID string, messageID int64) error {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"reply_markup": map[string]interface{}{"inline_keyboard": []interface{}{}},
|
||||
}
|
||||
return s.sendTelegramRequest("editMessageReplyMarkup", payload)
|
||||
}
|
||||
|
||||
// FindUserIDByChatID looks up a UserID by Telegram chat ID.
|
||||
func (s *NotificationService) FindUserIDByChatID(chatID string) (uint, error) {
|
||||
var settings models.UserNotificationSettings
|
||||
result := database.GetDB().Where("telegram_chat_id = ? AND telegram_enabled = ?", chatID, true).First(&settings)
|
||||
if result.Error != nil {
|
||||
return 0, fmt.Errorf("user not found for chat ID %s", chatID)
|
||||
}
|
||||
return settings.UserID, nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
|
||||
@@ -139,12 +158,6 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
@@ -162,7 +175,7 @@ func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, ass
|
||||
return nil
|
||||
}
|
||||
|
||||
if !settings.TelegramEnabled && !settings.LineEnabled {
|
||||
if !settings.TelegramEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -183,12 +196,6 @@ func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, ass
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
@@ -215,10 +222,11 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
|
||||
return err
|
||||
}
|
||||
|
||||
timeRemaining := time.Until(assignment.DueDate)
|
||||
softDue := assignment.GetEffectiveSoftDueDate()
|
||||
timeRemaining := time.Until(softDue)
|
||||
var timeStr string
|
||||
if timeRemaining < 0 {
|
||||
timeStr = "期限切れ!"
|
||||
timeStr = "自分の期限切れ!"
|
||||
} else if timeRemaining < time.Hour {
|
||||
timeStr = fmt.Sprintf("あと%d分", int(timeRemaining.Minutes()))
|
||||
} else {
|
||||
@@ -236,28 +244,33 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
|
||||
}
|
||||
|
||||
message := fmt.Sprintf(
|
||||
"%s 督促通知!\n\n【%s】\n科目: %s\n期限: %s (%s)\n\n完了したらアプリで完了ボタンを押してください!",
|
||||
"%s 督促通知!\n\n【%s】\n科目: %s\n自分の期限: %s (%s)\nガチ期限: %s\n\n完了したらアプリで完了ボタンを押してください!",
|
||||
priorityEmoji,
|
||||
assignment.Title,
|
||||
assignment.Subject,
|
||||
assignment.DueDate.Format("2006/01/02 15:04"),
|
||||
softDue.Format("2006/01/02 15:04"),
|
||||
timeStr,
|
||||
assignment.DueDate.Format("2006/01/02 15:04"),
|
||||
)
|
||||
|
||||
keyboard := &InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]InlineKeyboardButton{
|
||||
{
|
||||
{Text: "✅ 完了", CallbackData: fmt.Sprintf("done:%d", assignment.ID)},
|
||||
{Text: "⏰ 15分後", CallbackData: fmt.Sprintf("snooze15:%d", assignment.ID)},
|
||||
{Text: "💬 今やってる", CallbackData: fmt.Sprintf("working:%d", assignment.ID)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var errors []string
|
||||
|
||||
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||
if err := s.SendMessageWithKeyboard(settings.TelegramChatID, message, keyboard); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
@@ -319,9 +332,10 @@ func (s *NotificationService) ProcessUrgentReminders() {
|
||||
}
|
||||
|
||||
for _, assignment := range assignments {
|
||||
timeUntilDue := assignment.DueDate.Sub(now)
|
||||
softDue := assignment.GetEffectiveSoftDueDate()
|
||||
timeUntilSoftDue := softDue.Sub(now)
|
||||
|
||||
if timeUntilDue > urgentStartTime {
|
||||
if timeUntilSoftDue > urgentStartTime {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ var (
|
||||
ErrRecurringUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidRecurrenceType = errors.New("invalid recurrence type")
|
||||
ErrInvalidEndType = errors.New("invalid end type")
|
||||
ErrLeadDaysTooLarge = errors.New("generation_lead_days must be less than the recurrence interval")
|
||||
)
|
||||
|
||||
type RecurringAssignmentService struct {
|
||||
@@ -47,6 +48,8 @@ type CreateRecurringAssignmentInput struct {
|
||||
ReminderEnabled bool
|
||||
ReminderOffset *int
|
||||
UrgentReminderEnabled bool
|
||||
GenerationLeadDays int
|
||||
GenerationLeadTime string
|
||||
FirstDueDate time.Time
|
||||
}
|
||||
|
||||
@@ -62,6 +65,13 @@ func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAs
|
||||
if input.RecurrenceInterval < 1 {
|
||||
input.RecurrenceInterval = 1
|
||||
}
|
||||
|
||||
if input.GenerationLeadDays > 0 {
|
||||
maxLead := maxGenerationLeadDays(input.RecurrenceType, input.RecurrenceInterval)
|
||||
if input.GenerationLeadDays > maxLead {
|
||||
return nil, ErrLeadDaysTooLarge
|
||||
}
|
||||
}
|
||||
if input.EditBehavior == "" {
|
||||
input.EditBehavior = models.EditBehaviorThisOnly
|
||||
}
|
||||
@@ -84,6 +94,8 @@ func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAs
|
||||
ReminderEnabled: input.ReminderEnabled,
|
||||
ReminderOffset: input.ReminderOffset,
|
||||
UrgentReminderEnabled: input.UrgentReminderEnabled,
|
||||
GenerationLeadDays: input.GenerationLeadDays,
|
||||
GenerationLeadTime: input.GenerationLeadTime,
|
||||
IsActive: true,
|
||||
GeneratedCount: 0,
|
||||
}
|
||||
@@ -137,6 +149,8 @@ type UpdateRecurringInput struct {
|
||||
ReminderEnabled *bool
|
||||
ReminderOffset *int
|
||||
UrgentReminderEnabled *bool
|
||||
GenerationLeadDays *int
|
||||
GenerationLeadTime *string
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
|
||||
@@ -195,6 +209,18 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda
|
||||
if input.EndDate != nil {
|
||||
recurring.EndDate = input.EndDate
|
||||
}
|
||||
if input.GenerationLeadDays != nil && *input.GenerationLeadDays >= 0 {
|
||||
if *input.GenerationLeadDays > 0 {
|
||||
maxLead := maxGenerationLeadDays(recurring.RecurrenceType, recurring.RecurrenceInterval)
|
||||
if *input.GenerationLeadDays > maxLead {
|
||||
return nil, ErrLeadDaysTooLarge
|
||||
}
|
||||
}
|
||||
recurring.GenerationLeadDays = *input.GenerationLeadDays
|
||||
}
|
||||
if input.GenerationLeadTime != nil {
|
||||
recurring.GenerationLeadTime = *input.GenerationLeadTime
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Update(recurring); err != nil {
|
||||
return nil, err
|
||||
@@ -218,26 +244,27 @@ func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
|
||||
assignment *models.Assignment,
|
||||
title, description, subject, priority string,
|
||||
dueDate time.Time,
|
||||
softDueDate *time.Time,
|
||||
reminderEnabled bool,
|
||||
reminderAt *time.Time,
|
||||
urgentReminderEnabled bool,
|
||||
editBehavior string,
|
||||
) error {
|
||||
if assignment.RecurringAssignmentID == nil {
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
}
|
||||
|
||||
recurring, err := s.GetByID(userID, *assignment.RecurringAssignmentID)
|
||||
if err != nil {
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
}
|
||||
|
||||
switch editBehavior {
|
||||
case models.EditBehaviorThisOnly:
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
|
||||
case models.EditBehaviorThisAndFuture:
|
||||
if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil {
|
||||
if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
recurring.Title = title
|
||||
@@ -262,7 +289,7 @@ func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
|
||||
return s.updateAllPendingAssignments(recurring.ID, title, description, subject, priority, urgentReminderEnabled)
|
||||
|
||||
default:
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +297,7 @@ func (s *RecurringAssignmentService) updateSingleAssignment(
|
||||
assignment *models.Assignment,
|
||||
title, description, subject, priority string,
|
||||
dueDate time.Time,
|
||||
softDueDate *time.Time,
|
||||
reminderEnabled bool,
|
||||
reminderAt *time.Time,
|
||||
urgentReminderEnabled bool,
|
||||
@@ -279,6 +307,7 @@ func (s *RecurringAssignmentService) updateSingleAssignment(
|
||||
assignment.Subject = subject
|
||||
assignment.Priority = priority
|
||||
assignment.DueDate = dueDate
|
||||
assignment.SoftDueDate = softDueDate
|
||||
assignment.ReminderEnabled = reminderEnabled
|
||||
assignment.ReminderAt = reminderAt
|
||||
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
||||
@@ -366,33 +395,61 @@ func (s *RecurringAssignmentService) GenerateNextAssignments() error {
|
||||
}
|
||||
|
||||
for _, recurring := range recurrings {
|
||||
pendingCount, err := s.recurringRepo.CountPendingByRecurringID(recurring.ID)
|
||||
if err != nil {
|
||||
if err := s.generateNextIfPending(&recurring); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if pendingCount == 0 {
|
||||
latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var nextDueDate time.Time
|
||||
if latest != nil {
|
||||
nextDueDate = recurring.CalculateNextDueDate(latest.DueDate)
|
||||
} else {
|
||||
nextDueDate = time.Now()
|
||||
}
|
||||
|
||||
if nextDueDate.After(time.Now()) {
|
||||
s.generateAssignment(&recurring, nextDueDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) TriggerForRecurring(recurringID uint) error {
|
||||
recurring, err := s.recurringRepo.FindByID(recurringID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return s.generateNextIfPending(recurring)
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) generateNextIfPending(recurring *models.RecurringAssignment) error {
|
||||
if !recurring.ShouldGenerateNext() {
|
||||
return nil
|
||||
}
|
||||
|
||||
latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !latest.IsCompleted && !latest.IsOverdue() {
|
||||
return nil
|
||||
}
|
||||
|
||||
nextDueDate := recurring.CalculateNextDueDate(latest.DueDate)
|
||||
|
||||
if recurring.GenerationLeadDays > 0 {
|
||||
generationAt := nextDueDate.AddDate(0, 0, -recurring.GenerationLeadDays)
|
||||
if recurring.GenerationLeadTime != "" {
|
||||
parts := strings.Split(recurring.GenerationLeadTime, ":")
|
||||
if len(parts) == 2 {
|
||||
hour, _ := strconv.Atoi(parts[0])
|
||||
min, _ := strconv.Atoi(parts[1])
|
||||
generationAt = time.Date(generationAt.Year(), generationAt.Month(), generationAt.Day(), hour, min, 0, 0, generationAt.Location())
|
||||
}
|
||||
} else {
|
||||
generationAt = time.Date(generationAt.Year(), generationAt.Month(), generationAt.Day(), 0, 0, 0, 0, generationAt.Location())
|
||||
}
|
||||
if time.Now().Before(generationAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return s.generateAssignment(recurring, nextDueDate)
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) generateAssignment(recurring *models.RecurringAssignment, dueDate time.Time) error {
|
||||
if recurring.DueTime != "" {
|
||||
parts := strings.Split(recurring.DueTime, ":")
|
||||
@@ -403,19 +460,30 @@ func (s *RecurringAssignmentService) generateAssignment(recurring *models.Recurr
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := s.assignmentRepo.FindByRecurringAndDue(recurring.ID, dueDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var reminderAt *time.Time
|
||||
if recurring.ReminderEnabled && recurring.ReminderOffset != nil {
|
||||
t := dueDate.Add(-time.Duration(*recurring.ReminderOffset) * time.Minute)
|
||||
reminderAt = &t
|
||||
}
|
||||
|
||||
softDue := dueDate.Add(-48 * time.Hour)
|
||||
|
||||
assignment := &models.Assignment{
|
||||
UserID: userID(recurring.UserID),
|
||||
UserID: recurring.UserID,
|
||||
Title: recurring.Title,
|
||||
Description: recurring.Description,
|
||||
Subject: recurring.Subject,
|
||||
Priority: recurring.Priority,
|
||||
DueDate: dueDate,
|
||||
SoftDueDate: &softDue,
|
||||
ReminderEnabled: recurring.ReminderEnabled,
|
||||
ReminderAt: reminderAt,
|
||||
UrgentReminderEnabled: recurring.UrgentReminderEnabled,
|
||||
@@ -430,8 +498,17 @@ func (s *RecurringAssignmentService) generateAssignment(recurring *models.Recurr
|
||||
return s.recurringRepo.Update(recurring)
|
||||
}
|
||||
|
||||
func userID(id uint) uint {
|
||||
return id
|
||||
|
||||
func maxGenerationLeadDays(recurrenceType string, interval int) int {
|
||||
switch recurrenceType {
|
||||
case models.RecurrenceDaily:
|
||||
return interval
|
||||
case models.RecurrenceWeekly:
|
||||
return interval * 7
|
||||
case models.RecurrenceMonthly:
|
||||
return interval * 28
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isValidRecurrenceType(t string) bool {
|
||||
|
||||
423
internal/service/telegram_service.go
Normal file
423
internal/service/telegram_service.go
Normal file
@@ -0,0 +1,423 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/database"
|
||||
)
|
||||
|
||||
// TelegramUpdate is the top-level object sent by Telegram to the webhook.
|
||||
type TelegramUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *TelegramMessage `json:"message,omitempty"`
|
||||
CallbackQuery *TelegramCallback `json:"callback_query,omitempty"`
|
||||
}
|
||||
|
||||
// TelegramMessage is an incoming text message from a user.
|
||||
type TelegramMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
From TelegramUser `json:"from"`
|
||||
Chat TelegramChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TelegramChat holds the chat metadata.
|
||||
type TelegramChat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// TelegramUser holds basic sender information.
|
||||
type TelegramUser struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
}
|
||||
|
||||
// TelegramCallback is sent when the user presses an inline keyboard button.
|
||||
type TelegramCallback struct {
|
||||
ID string `json:"id"`
|
||||
From TelegramUser `json:"from"`
|
||||
Message *TelegramMessage `json:"message,omitempty"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// TelegramService handles incoming Telegram updates and drives bot interactions.
|
||||
type TelegramService struct {
|
||||
notifService *NotificationService
|
||||
assignmentService *AssignmentService
|
||||
}
|
||||
|
||||
// NewTelegramService creates a TelegramService.
|
||||
func NewTelegramService(notifService *NotificationService, assignmentService *AssignmentService) *TelegramService {
|
||||
return &TelegramService{
|
||||
notifService: notifService,
|
||||
assignmentService: assignmentService,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdate routes an incoming Telegram update to the appropriate handler.
|
||||
func (s *TelegramService) HandleUpdate(update TelegramUpdate) {
|
||||
switch {
|
||||
case update.Message != nil:
|
||||
s.handleMessage(update.Message)
|
||||
case update.CallbackQuery != nil:
|
||||
s.handleCallbackQuery(update.CallbackQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Message handlers ---
|
||||
|
||||
func (s *TelegramService) handleMessage(msg *TelegramMessage) {
|
||||
chatID := strconv.FormatInt(msg.Chat.ID, 10)
|
||||
text := strings.TrimSpace(msg.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := s.notifService.FindUserIDByChatID(chatID)
|
||||
if err != nil {
|
||||
_ = s.notifService.SendTelegramNotification(chatID,
|
||||
"このBotはSuper Homework Managerと連携されていません。\nアプリのプロフィール画面でChat IDを登録してください。")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "/add":
|
||||
s.handleAdd(chatID, userID, args)
|
||||
case "/list":
|
||||
s.handleList(chatID, userID)
|
||||
case "/done":
|
||||
s.handleDone(chatID, userID, args)
|
||||
case "/help", "/start":
|
||||
s.handleHelp(chatID)
|
||||
default:
|
||||
_ = s.notifService.SendTelegramNotification(chatID,
|
||||
fmt.Sprintf("不明なコマンドです: %s\n\n%s", escapeHTML(cmd), addUsageShort()))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramService) handleAdd(chatID string, userID uint, args string) {
|
||||
if args == "" {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, helpText())
|
||||
return
|
||||
}
|
||||
|
||||
title, dueDate, subject, priority, err := ParseAddCommand(args)
|
||||
if err != nil {
|
||||
if err.Error() == "usage" {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, addUsageShort())
|
||||
return
|
||||
}
|
||||
_ = s.notifService.SendTelegramNotification(chatID,
|
||||
fmt.Sprintf("❌ %s\n\n%s", err.Error(), addUsageShort()))
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := s.assignmentService.Create(
|
||||
userID, title, "", subject, priority, dueDate, nil, false, nil, true,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("telegram /add error userID=%d: %v", userID, err)
|
||||
_ = s.notifService.SendTelegramNotification(chatID, "❌ 課題の登録に失敗しました。")
|
||||
return
|
||||
}
|
||||
|
||||
softDue := assignment.GetEffectiveSoftDueDate()
|
||||
priorityLabel := map[string]string{"high": "高", "medium": "中", "low": "低"}[priority]
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "✅ 課題を登録しました\n\n📌 <b>%s</b>", escapeHTML(title))
|
||||
if subject != "" {
|
||||
fmt.Fprintf(&sb, "\n科目: %s", escapeHTML(subject))
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n優先度: %s\nガチ期限: %s\n自分の期限: %s(2日前)",
|
||||
priorityLabel,
|
||||
dueDate.Format("2006/01/02 15:04"),
|
||||
softDue.Format("2006/01/02 15:04"),
|
||||
)
|
||||
|
||||
keyboard := &InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]InlineKeyboardButton{
|
||||
{{Text: "🗑 取り消す", CallbackData: fmt.Sprintf("undo:%d", assignment.ID)}},
|
||||
},
|
||||
}
|
||||
_ = s.notifService.SendMessageWithKeyboard(chatID, sb.String(), keyboard)
|
||||
}
|
||||
|
||||
func (s *TelegramService) handleList(chatID string, userID uint) {
|
||||
assignments, err := s.assignmentService.GetPendingByUser(userID)
|
||||
if err != nil {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, "❌ 課題一覧の取得に失敗しました。")
|
||||
return
|
||||
}
|
||||
|
||||
if len(assignments) == 0 {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, "✨ 未完了の課題はありません!")
|
||||
return
|
||||
}
|
||||
|
||||
totalCount := len(assignments)
|
||||
sort.Slice(assignments, func(i, j int) bool {
|
||||
return assignments[i].DueDate.Before(assignments[j].DueDate)
|
||||
})
|
||||
if len(assignments) > 5 {
|
||||
assignments = assignments[:5]
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if totalCount > 5 {
|
||||
fmt.Fprintf(&sb, "📋 未完了課題(%d件中上位5件)\n\n", totalCount)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "📋 未完了課題(%d件)\n\n", totalCount)
|
||||
}
|
||||
|
||||
for _, a := range assignments {
|
||||
fmt.Fprintf(&sb, "%s <b>#%d</b> %s\n", priorityEmoji(a.Priority), a.ID, escapeHTML(a.Title))
|
||||
if a.Subject != "" {
|
||||
fmt.Fprintf(&sb, " 科目: %s\n", escapeHTML(a.Subject))
|
||||
}
|
||||
fmt.Fprintf(&sb, " %s %s\n\n", a.DueDate.Format("1/2 15:04"), formatTimeUntil(a.DueDate))
|
||||
}
|
||||
|
||||
sb.WriteString("完了: /done <番号>")
|
||||
_ = s.notifService.SendTelegramNotification(chatID, sb.String())
|
||||
}
|
||||
|
||||
func (s *TelegramService) handleDone(chatID string, userID uint, args string) {
|
||||
args = strings.TrimPrefix(strings.TrimSpace(args), "#")
|
||||
id, err := strconv.ParseUint(args, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
_ = s.notifService.SendTelegramNotification(chatID,
|
||||
"❌ 使い方: /done <課題番号>\n例: /done 42\n\n課題番号は /list で確認できます。")
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := s.assignmentService.GetByID(userID, uint(id))
|
||||
if err != nil {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, "❌ 課題が見つかりませんでした。")
|
||||
return
|
||||
}
|
||||
if assignment.IsCompleted {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, "ℹ️ この課題はすでに完了済みです。")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.assignmentService.ToggleComplete(userID, uint(id)); err != nil {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, "❌ 完了処理に失敗しました。")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.notifService.SendTelegramNotification(chatID,
|
||||
fmt.Sprintf("✅ 完了!お疲れ様!\n\n【%s】", escapeHTML(assignment.Title)))
|
||||
}
|
||||
|
||||
func (s *TelegramService) handleHelp(chatID string) {
|
||||
_ = s.notifService.SendTelegramNotification(chatID, helpText())
|
||||
}
|
||||
|
||||
// --- Callback query handlers ---
|
||||
|
||||
// handleCallbackQuery processes inline button presses.
|
||||
func (s *TelegramService) handleCallbackQuery(cb *TelegramCallback) {
|
||||
chatID := strconv.FormatInt(cb.From.ID, 10)
|
||||
|
||||
userID, err := s.notifService.FindUserIDByChatID(chatID)
|
||||
if err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cb.ID, "ユーザーが見つかりません")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(cb.Data, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
_ = s.notifService.AnswerCallbackQuery(cb.ID, "")
|
||||
return
|
||||
}
|
||||
|
||||
action := parts[0]
|
||||
assignmentID, err := strconv.ParseUint(parts[1], 10, 64)
|
||||
if err != nil || assignmentID == 0 {
|
||||
_ = s.notifService.AnswerCallbackQuery(cb.ID, "")
|
||||
return
|
||||
}
|
||||
|
||||
var msgID int64
|
||||
if cb.Message != nil {
|
||||
msgID = cb.Message.MessageID
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "done":
|
||||
s.callbackDone(chatID, userID, uint(assignmentID), cb.ID, msgID)
|
||||
case "snooze15":
|
||||
s.callbackSnooze(chatID, userID, uint(assignmentID), 15, cb.ID, msgID)
|
||||
case "working":
|
||||
s.callbackSnooze(chatID, userID, uint(assignmentID), 60, cb.ID, msgID)
|
||||
case "undo":
|
||||
s.callbackUndo(chatID, userID, uint(assignmentID), cb.ID, msgID)
|
||||
default:
|
||||
_ = s.notifService.AnswerCallbackQuery(cb.ID, "")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramService) callbackDone(chatID string, userID uint, assignmentID uint, cbID string, msgID int64) {
|
||||
assignment, err := s.assignmentService.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "課題が見つかりません")
|
||||
return
|
||||
}
|
||||
if assignment.IsCompleted {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "すでに完了済みです")
|
||||
_ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID)
|
||||
return
|
||||
}
|
||||
if _, err := s.assignmentService.ToggleComplete(userID, assignmentID); err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "エラーが発生しました")
|
||||
return
|
||||
}
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "✅ 完了!お疲れ様!")
|
||||
_ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID)
|
||||
}
|
||||
|
||||
func (s *TelegramService) callbackSnooze(chatID string, userID uint, assignmentID uint, snoozeMin int, cbID string, msgID int64) {
|
||||
assignment, err := s.assignmentService.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "課題が見つかりません")
|
||||
return
|
||||
}
|
||||
if assignment.IsCompleted {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "すでに完了済みです")
|
||||
_ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID)
|
||||
return
|
||||
}
|
||||
|
||||
interval := getUrgentReminderInterval(assignment.Priority)
|
||||
// Set last_sent so that next fire happens after snoozeMin minutes.
|
||||
// Condition fires when: now - lastSent >= interval
|
||||
// We want it to fire at: T + snoozeMin
|
||||
// So set: lastSent = T + snoozeMin - interval
|
||||
newLastSent := time.Now().Add(time.Duration(snoozeMin)*time.Minute - interval)
|
||||
if err := database.GetDB().Model(assignment).Update("last_urgent_reminder_sent", newLastSent).Error; err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "エラーが発生しました")
|
||||
return
|
||||
}
|
||||
|
||||
var label string
|
||||
if snoozeMin == 60 {
|
||||
label = "1時間後に通知します。頑張れ!"
|
||||
} else {
|
||||
label = fmt.Sprintf("%d分後に通知します", snoozeMin)
|
||||
}
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, label)
|
||||
_ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID)
|
||||
}
|
||||
|
||||
func (s *TelegramService) callbackUndo(chatID string, userID uint, assignmentID uint, cbID string, msgID int64) {
|
||||
assignment, err := s.assignmentService.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "課題が見つかりません")
|
||||
return
|
||||
}
|
||||
if time.Since(assignment.CreatedAt) > 60*time.Second {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "時間切れです(60秒以内のみ取り消し可能)")
|
||||
_ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID)
|
||||
return
|
||||
}
|
||||
if err := s.assignmentService.Delete(userID, assignmentID); err != nil {
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "エラーが発生しました")
|
||||
return
|
||||
}
|
||||
_ = s.notifService.AnswerCallbackQuery(cbID, "🗑 取り消しました")
|
||||
_ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID)
|
||||
}
|
||||
|
||||
// --- Text helpers ---
|
||||
|
||||
func priorityEmoji(p string) string {
|
||||
switch p {
|
||||
case "high":
|
||||
return "🚨"
|
||||
case "medium":
|
||||
return "⚠️"
|
||||
default:
|
||||
return "📌"
|
||||
}
|
||||
}
|
||||
|
||||
func formatTimeUntil(t time.Time) string {
|
||||
d := time.Until(t)
|
||||
if d < 0 {
|
||||
return "(期限切れ)"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("(あと%d分)", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("(あと%d時間)", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("(あと%d日)", int(d.Hours()/24))
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return `📖 <b>Super Homework Manager Bot</b>
|
||||
|
||||
<b>コマンド一覧</b>
|
||||
|
||||
/add <タイトル> <日付> [時刻] [#科目] [!優先度]
|
||||
課題を追加します
|
||||
|
||||
/list
|
||||
未完了課題を一覧表示(最大5件)
|
||||
|
||||
/done <番号>
|
||||
課題を完了にします(/list の#番号)
|
||||
|
||||
/help
|
||||
このメッセージを表示
|
||||
|
||||
<b>日付の書き方</b>
|
||||
今日 / 明日 / 明後日
|
||||
月 火 水 木 金 土 日
|
||||
1/15 または 1月15日
|
||||
|
||||
<b>時刻の書き方</b>(省略時: 23:59)
|
||||
23:59 / 17時 / 17時30分
|
||||
|
||||
<b>オプション</b>
|
||||
#科目名 → 例: #数学
|
||||
!高 / !中 / !低 → 優先度(省略時: 中)
|
||||
|
||||
<b>例</b>
|
||||
/add レポート 金曜 23:59 #英語 !高
|
||||
/add 数学課題 明日
|
||||
/add 情報演習 1/20 17時`
|
||||
}
|
||||
|
||||
func addUsageShort() string {
|
||||
return `使い方: /add <タイトル> <日付> [時刻] [#科目] [!優先度]
|
||||
|
||||
例:
|
||||
/add レポート 金曜 23:59 #英語 !高
|
||||
/add 数学課題 明日
|
||||
/add 情報演習 1/20 17時
|
||||
|
||||
詳細: /help`
|
||||
}
|
||||
71
internal/service/totp_service.go
Normal file
71
internal/service/totp_service.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"net/url"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
totplib "github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
type TOTPService struct{}
|
||||
|
||||
func NewTOTPService() *TOTPService {
|
||||
return &TOTPService{}
|
||||
}
|
||||
|
||||
type TOTPSetupData struct {
|
||||
Secret string
|
||||
QRCodeB64 string
|
||||
OTPAuthURL string
|
||||
}
|
||||
|
||||
func (s *TOTPService) GenerateSecret(email, issuer string) (*TOTPSetupData, error) {
|
||||
key, err := totplib.Generate(totplib.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: email,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.buildSetupData(key)
|
||||
}
|
||||
|
||||
func (s *TOTPService) SetupDataFromSecret(secret, email, issuer string) (*TOTPSetupData, error) {
|
||||
otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s",
|
||||
url.PathEscape(issuer),
|
||||
url.PathEscape(email),
|
||||
url.QueryEscape(secret),
|
||||
url.QueryEscape(issuer),
|
||||
)
|
||||
key, err := otp.NewKeyFromURL(otpURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.buildSetupData(key)
|
||||
}
|
||||
|
||||
func (s *TOTPService) buildSetupData(key *otp.Key) (*TOTPSetupData, error) {
|
||||
img, err := key.Image(200, 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TOTPSetupData{
|
||||
Secret: key.Secret(),
|
||||
QRCodeB64: base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
OTPAuthURL: key.URL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TOTPService) Validate(secret, code string) bool {
|
||||
return totplib.Validate(code, secret)
|
||||
}
|
||||
173
internal/validation/validation.go
Normal file
173
internal/validation/validation.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var MaxLengths = map[string]int{
|
||||
"title": 200,
|
||||
"description": 5000,
|
||||
"subject": 100,
|
||||
"priority": 20,
|
||||
}
|
||||
|
||||
var xssPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)<\s*script`),
|
||||
regexp.MustCompile(`(?i)</\s*script`),
|
||||
regexp.MustCompile(`(?i)javascript\s*:`),
|
||||
regexp.MustCompile(`(?i)on\w+\s*=`),
|
||||
regexp.MustCompile(`(?i)<\s*iframe`),
|
||||
regexp.MustCompile(`(?i)<\s*object`),
|
||||
regexp.MustCompile(`(?i)<\s*embed`),
|
||||
regexp.MustCompile(`(?i)<\s*svg[^>]*on\w+\s*=`),
|
||||
regexp.MustCompile(`(?i)data\s*:\s*text/html`),
|
||||
regexp.MustCompile(`(?i)<\s*img[^>]*on\w+\s*=`),
|
||||
regexp.MustCompile(`(?i)expression\s*\(`),
|
||||
regexp.MustCompile(`(?i)alert\s*\(`),
|
||||
regexp.MustCompile(`(?i)confirm\s*\(`),
|
||||
regexp.MustCompile(`(?i)prompt\s*\(`),
|
||||
regexp.MustCompile(`(?i)document\s*\.\s*cookie`),
|
||||
regexp.MustCompile(`(?i)document\s*\.\s*location`),
|
||||
regexp.MustCompile(`(?i)window\s*\.\s*location`),
|
||||
}
|
||||
|
||||
var sqlInjectionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)'\s*or\s+`),
|
||||
regexp.MustCompile(`(?i)'\s*and\s+`),
|
||||
regexp.MustCompile(`(?i)"\s*or\s+`),
|
||||
regexp.MustCompile(`(?i)"\s*and\s+`),
|
||||
regexp.MustCompile(`(?i)union\s+(all\s+)?select`),
|
||||
regexp.MustCompile(`(?i);\s*(drop|delete|update|insert|alter|truncate)\s+`),
|
||||
regexp.MustCompile(`(?i)--\s*$`),
|
||||
regexp.MustCompile(`(?i)/\*.*\*/`),
|
||||
regexp.MustCompile(`(?i)'\s*;\s*`),
|
||||
regexp.MustCompile(`(?i)exec\s*\(`),
|
||||
regexp.MustCompile(`(?i)xp_\w+`),
|
||||
regexp.MustCompile(`(?i)load_file\s*\(`),
|
||||
regexp.MustCompile(`(?i)into\s+(out|dump)file`),
|
||||
regexp.MustCompile(`(?i)benchmark\s*\(`),
|
||||
regexp.MustCompile(`(?i)sleep\s*\(\s*\d`),
|
||||
regexp.MustCompile(`(?i)waitfor\s+delay`),
|
||||
regexp.MustCompile(`(?i)1\s*=\s*1`),
|
||||
regexp.MustCompile(`(?i)'1'\s*=\s*'1`),
|
||||
}
|
||||
|
||||
var pathTraversalPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\.\.[\\/]`),
|
||||
regexp.MustCompile(`\.\.%2[fF]`),
|
||||
regexp.MustCompile(`%2e%2e[\\/]`),
|
||||
regexp.MustCompile(`\.\./`),
|
||||
regexp.MustCompile(`\.\.\\`),
|
||||
}
|
||||
|
||||
var commandInjectionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^\s*;`),
|
||||
regexp.MustCompile(`;\s*\w+`),
|
||||
regexp.MustCompile(`\|\s*\w+`),
|
||||
regexp.MustCompile("`[^`]+`"),
|
||||
regexp.MustCompile(`\$\([^)]+\)`),
|
||||
regexp.MustCompile(`&&\s*\w+`),
|
||||
regexp.MustCompile(`\|\|\s*\w+`),
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
func ValidateAssignmentInput(title, description, subject, priority string) error {
|
||||
if err := ValidateField("title", title, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateField("description", description, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateField("subject", subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateField("priority", priority, false); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateField(fieldName, value string, required bool) error {
|
||||
if required && strings.TrimSpace(value) == "" {
|
||||
return &ValidationError{Field: fieldName, Message: "必須項目です"}
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if maxLen, ok := MaxLengths[fieldName]; ok {
|
||||
if len(value) > maxLen {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("最大%d文字までです", maxLen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fieldName != "description" {
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "不正な制御文字が含まれています",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range xssPatterns {
|
||||
if pattern.MatchString(value) {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "潜在的に危険なHTMLタグまたはスクリプトが含まれています",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range sqlInjectionPatterns {
|
||||
if pattern.MatchString(value) {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "潜在的に危険なSQL構文が含まれています",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range pathTraversalPatterns {
|
||||
if pattern.MatchString(value) {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "不正なパス文字列が含まれています",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range commandInjectionPatterns {
|
||||
if pattern.MatchString(value) {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "潜在的に危険なコマンド構文が含まれています",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizeString(s string) string {
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
s = strings.TrimSpace(s)
|
||||
return s
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
/* Custom styles for Homework Manager */
|
||||
|
||||
:root {
|
||||
--primary-color: #4361ee;
|
||||
--secondary-color: #3f37c9;
|
||||
@@ -13,8 +11,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f8f9fa;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
@@ -27,7 +25,6 @@ main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
@@ -44,7 +41,6 @@ main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Navbar customization */
|
||||
.navbar {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -53,7 +49,6 @@ main {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table improvements */
|
||||
.table {
|
||||
background-color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
@@ -69,7 +64,6 @@ main {
|
||||
background-color: rgba(67, 97, 238, 0.05);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
@@ -85,25 +79,21 @@ main {
|
||||
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,
|
||||
@@ -119,27 +109,38 @@ main {
|
||||
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;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #6c757d;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: #000;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
border-bottom: 3px solid #000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* Login/Register cards */
|
||||
.card.shadow {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Empty states */
|
||||
.text-muted.display-1 {
|
||||
color: #dee2e6 !important;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
@@ -150,50 +151,82 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
padding: 0.35rem 0.5rem !important;
|
||||
.custom-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.custom-table thead th {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-size: 0.8rem;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.custom-table tbody td {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
vertical-align: middle;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 0.75rem !important;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-control-custom,
|
||||
.form-select-custom,
|
||||
.btn-custom {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.bi-circle,
|
||||
.bi-check-circle-fill {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.countdown-urgent {
|
||||
color: #dc3545;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.countdown-warning {
|
||||
color: #fd7e14;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Animations for Anxiety/Urgency */
|
||||
@keyframes pulse-bg {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #ffe69c;
|
||||
}
|
||||
0%, 100% { background-color: #fff3cd; }
|
||||
50% { background-color: #ffe69c; }
|
||||
}
|
||||
|
||||
@keyframes pulse-bg-danger {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #f5c2c7;
|
||||
}
|
||||
0%, 100% { background-color: #f8d7da; }
|
||||
50% { background-color: #f5c2c7; }
|
||||
}
|
||||
|
||||
@keyframes blink-text {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@keyframes blink-banner {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.anxiety-warning {
|
||||
@@ -204,77 +237,277 @@ main {
|
||||
animation: pulse-bg-danger 1s infinite;
|
||||
}
|
||||
|
||||
/* Custom Table Styles - Compact */
|
||||
.custom-table thead th {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
.urgent-banner {
|
||||
position: relative;
|
||||
animation: blink-banner 1.5s infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, ::before, ::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
#weekday_group,
|
||||
#day_group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-touch {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copy-feedback {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.copy-feedback.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.title-clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sort-th a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sort-th a:hover { color: var(--primary-color); }
|
||||
|
||||
.subject-group-row td {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
white-space: nowrap;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
transition: background-color 0.2s;
|
||||
.assignment-row[data-priority="high"] td:first-child {
|
||||
box-shadow: inset 3px 0 0 #dc3545;
|
||||
}
|
||||
.assignment-row[data-priority="medium"] td:first-child {
|
||||
box-shadow: inset 3px 0 0 #fd7e14;
|
||||
}
|
||||
.assignment-row[data-priority="low"] td:first-child {
|
||||
box-shadow: inset 3px 0 0 #adb5bd;
|
||||
}
|
||||
|
||||
.custom-table tbody td {
|
||||
tr.row-pinned {
|
||||
background-color: #fffbe6 !important;
|
||||
}
|
||||
tr.row-pinned:hover {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
.pin-btn.pinned { color: #ffc107; }
|
||||
.pin-btn:not(.pinned) { color: #dee2e6; }
|
||||
.pin-btn:not(.pinned):hover { color: #ffc107; }
|
||||
|
||||
#bulkBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #4361ee;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kanban-col-body {
|
||||
min-height: 100px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.kanban-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.kanban-card:last-child { margin-bottom: 0; }
|
||||
|
||||
tr.kb-focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
table.custom-table,
|
||||
table.custom-table > tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.custom-table thead {
|
||||
display: none;
|
||||
}
|
||||
.custom-table tbody tr.assignment-row,
|
||||
.custom-table tbody tr.recurring-row,
|
||||
.custom-table tbody tr.user-row {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 0.75rem;
|
||||
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.assignment-row td,
|
||||
.custom-table tbody tr.recurring-row td,
|
||||
.custom-table tbody tr.user-row td {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
tr.assignment-row td:nth-child(1) {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
tr.assignment-row td:nth-child(2) {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 2.25rem;
|
||||
}
|
||||
tr.assignment-row td:nth-child(3) {
|
||||
display: inline-block;
|
||||
margin-left: 4rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
tr.assignment-row td:nth-child(4) {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
tr.assignment-row td:nth-child(5) {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-right: 4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
tr.assignment-row td:nth-child(6) {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
tr.assignment-row td:nth-child(7) {
|
||||
display: inline-block;
|
||||
}
|
||||
tr.assignment-row td:nth-child(8) {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
.countdown-col {
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Compact form elements */
|
||||
.form-control-custom,
|
||||
.form-select-custom,
|
||||
.btn-custom {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
tr.recurring-row td:nth-child(1) {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 3rem;
|
||||
}
|
||||
tr.recurring-row td:nth-child(2),
|
||||
tr.recurring-row td:nth-child(3),
|
||||
tr.recurring-row td:nth-child(4) {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
tr.recurring-row td:nth-child(5) {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
/* Custom Tab Styles */
|
||||
.nav-tabs .nav-link {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
tr.user-row td:nth-child(1),
|
||||
tr.user-row td:nth-child(5) {
|
||||
display: none !important;
|
||||
}
|
||||
tr.user-row td:nth-child(2) {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
tr.user-row td:nth-child(3) {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
/* text-muted */
|
||||
border: none;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
tr.user-row td:nth-child(4) {
|
||||
display: inline-block;
|
||||
}
|
||||
tr.user-row td:nth-child(6) {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
min-width: auto !important;
|
||||
}
|
||||
.stats-table thead th:nth-child(3),
|
||||
.stats-table thead th:nth-child(4),
|
||||
.stats-table thead th:nth-child(5) {
|
||||
display: none !important;
|
||||
}
|
||||
tr.subject-row td:nth-child(3),
|
||||
tr.subject-row td:nth-child(4),
|
||||
tr.subject-row td:nth-child(5) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-stat-card .display-4 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
.dashboard-stat-card h2 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.dashboard-stat-card h6 {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.urgent-countdown {
|
||||
font-size: 1.05rem !important;
|
||||
padding-left: 0.75rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@media (max-width: 767.98px) {
|
||||
.nav-tabs#assignmentTabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.nav-tabs#assignmentTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.nav-tabs#assignmentTabs .ms-auto {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,133 @@
|
||||
// Homework Manager JavaScript
|
||||
const XSS = {
|
||||
escapeHtml: function (str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-dismiss alerts after 5 seconds (exclude alerts inside modals)
|
||||
setTextSafe: function (element, text) {
|
||||
if (element) element.textContent = text;
|
||||
},
|
||||
|
||||
sanitizeUrl: function (url) {
|
||||
if (!url) return '';
|
||||
const cleaned = String(url).replace(/[\x00-\x1F\x7F]/g, '').trim();
|
||||
try {
|
||||
const parsed = new URL(cleaned, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href;
|
||||
} catch (e) {
|
||||
if (cleaned.startsWith('/') && !cleaned.startsWith('//')) return cleaned;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
window.XSS = XSS;
|
||||
|
||||
var SUBJECT_PALETTE = [
|
||||
{ bg: '#4361ee', text: '#fff' },
|
||||
{ bg: '#7b2d8b', text: '#fff' },
|
||||
{ bg: '#2d9b4e', text: '#fff' },
|
||||
{ bg: '#c87800', text: '#fff' },
|
||||
{ bg: '#0077b6', text: '#fff' },
|
||||
{ bg: '#c1121f', text: '#fff' },
|
||||
{ bg: '#457b9d', text: '#fff' },
|
||||
{ bg: '#588157', text: '#fff' },
|
||||
{ bg: '#6d4c41', text: '#fff' },
|
||||
{ bg: '#6a4c93', text: '#fff' },
|
||||
];
|
||||
|
||||
function subjectColorFor(subject) {
|
||||
if (!subject) return null;
|
||||
var hash = 0;
|
||||
for (var i = 0; i < subject.length; i++) {
|
||||
hash = (hash * 31 + subject.charCodeAt(i)) | 0;
|
||||
}
|
||||
return SUBJECT_PALETTE[Math.abs(hash) % SUBJECT_PALETTE.length];
|
||||
}
|
||||
|
||||
function applySubjectColors() {
|
||||
document.querySelectorAll('.subject-badge[data-subject]').forEach(function (badge) {
|
||||
var color = subjectColorFor(badge.dataset.subject);
|
||||
if (color) {
|
||||
badge.style.backgroundColor = color.bg;
|
||||
badge.style.color = color.text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.subjectColorFor = subjectColorFor;
|
||||
|
||||
let _pendingConfirmForm = null;
|
||||
|
||||
function showConfirmModal(message, onOk) {
|
||||
const bodyEl = document.getElementById('confirmModalBody');
|
||||
const okBtn = document.getElementById('confirmModalOk');
|
||||
if (!bodyEl || !okBtn) { if (onOk) onOk(); return; }
|
||||
bodyEl.textContent = message;
|
||||
const handler = function () {
|
||||
okBtn.removeEventListener('click', handler);
|
||||
bootstrap.Modal.getInstance(document.getElementById('confirmModal')).hide();
|
||||
if (onOk) onOk();
|
||||
};
|
||||
okBtn.addEventListener('click', handler);
|
||||
new bootstrap.Modal(document.getElementById('confirmModal')).show();
|
||||
}
|
||||
|
||||
window.showConfirmModal = showConfirmModal;
|
||||
|
||||
function setupFormSubmitOnce(form) {
|
||||
form.addEventListener('submit', function () {
|
||||
const btn = form.querySelector('[type=submit]');
|
||||
if (!btn || btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>処理中...';
|
||||
window.addEventListener('pageshow', function () {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function showCopyFeedback(message) {
|
||||
let el = document.getElementById('globalCopyFeedback');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'globalCopyFeedback';
|
||||
el.className = 'copy-feedback alert alert-success shadow-sm py-2 px-3';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = message;
|
||||
el.classList.add('show');
|
||||
clearTimeout(el._timeout);
|
||||
el._timeout = setTimeout(function () { el.classList.remove('show'); }, 2000);
|
||||
}
|
||||
|
||||
window.showCopyFeedback = showCopyFeedback;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
alerts.forEach(function (alert) {
|
||||
setTimeout(function () {
|
||||
alert.classList.add('fade');
|
||||
setTimeout(function() {
|
||||
alert.remove();
|
||||
}, 150);
|
||||
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)) {
|
||||
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const msg = form.dataset.confirm;
|
||||
showConfirmModal(msg, function () { form.submit(); });
|
||||
});
|
||||
});
|
||||
|
||||
// Set default datetime to now + 1 day for new assignments
|
||||
document.querySelectorAll('form:not([data-confirm])').forEach(setupFormSubmitOnce);
|
||||
|
||||
applySubjectColors();
|
||||
|
||||
const dueDateInput = document.getElementById('due_date');
|
||||
if (dueDateInput && !dueDateInput.value) {
|
||||
const tomorrow = new Date();
|
||||
@@ -30,4 +135,462 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
tomorrow.setHours(23, 59, 0, 0);
|
||||
dueDateInput.value = tomorrow.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
initAssignmentIndex();
|
||||
});
|
||||
|
||||
function initAssignmentIndex() {
|
||||
if (!document.getElementById('tableView')) return;
|
||||
|
||||
var _countdownInterval = null;
|
||||
var _view = localStorage.getItem('viewMode') || 'table';
|
||||
var _grouped = localStorage.getItem('grouped') === 'true';
|
||||
var _kbFocusIndex = -1;
|
||||
|
||||
function getRows() {
|
||||
return Array.from(document.querySelectorAll('#tableView .assignment-row'));
|
||||
}
|
||||
|
||||
function updateCountdowns() {
|
||||
var now = new Date();
|
||||
var hasUnder24h = false;
|
||||
|
||||
getRows().forEach(function (row) {
|
||||
if (row.dataset.completed === 'true') return;
|
||||
var dueTs = row.dataset.dueTs;
|
||||
if (!dueTs) return;
|
||||
var due = new Date(parseInt(dueTs) * 1000);
|
||||
if (isNaN(due.getTime())) return;
|
||||
|
||||
var diff = due - now;
|
||||
var countdownEl = row.querySelector('.countdown');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var days = Math.floor(diff / 86400000);
|
||||
var hours = Math.floor((diff % 86400000) / 3600000);
|
||||
var minutes = Math.floor((diff % 3600000) / 60000);
|
||||
var seconds = Math.floor((diff % 60000) / 1000);
|
||||
var remainingHours = days * 24 + hours;
|
||||
|
||||
var text = (days > 0 ? days + '日 ' : '') +
|
||||
String(hours).padStart(2, '0') + ':' +
|
||||
String(minutes).padStart(2, '0') + ':' +
|
||||
String(seconds).padStart(2, '0');
|
||||
|
||||
if (countdownEl) countdownEl.textContent = text;
|
||||
|
||||
if (remainingHours < 24) {
|
||||
hasUnder24h = true;
|
||||
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" aria-hidden="true"></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" aria-hidden="true"></i>' + text;
|
||||
}
|
||||
} else {
|
||||
if (countdownEl) countdownEl.classList.add('text-secondary');
|
||||
}
|
||||
});
|
||||
|
||||
if (_countdownInterval !== null) return;
|
||||
var interval = hasUnder24h ? 1000 : 60000;
|
||||
_countdownInterval = setTimeout(function () { _countdownInterval = null; updateCountdowns(); }, interval);
|
||||
}
|
||||
|
||||
function toggleCountdown() {
|
||||
var cols = document.querySelectorAll('.countdown-col');
|
||||
var btn = document.getElementById('toggleCountdownBtn');
|
||||
var btnText = document.getElementById('countdownBtnText');
|
||||
var isHidden = cols[0] && cols[0].style.display === 'none';
|
||||
cols.forEach(function (col) { col.style.display = isHidden ? '' : 'none'; });
|
||||
var nowHidden = !isHidden;
|
||||
btnText.textContent = nowHidden ? '残り表示' : '残り非表示';
|
||||
btn.setAttribute('aria-pressed', nowHidden ? 'true' : 'false');
|
||||
localStorage.setItem('countdownHidden', nowHidden);
|
||||
}
|
||||
window.toggleCountdown = toggleCountdown;
|
||||
|
||||
function setView(mode) {
|
||||
_view = mode;
|
||||
localStorage.setItem('viewMode', mode);
|
||||
var tableView = document.getElementById('tableView');
|
||||
var kanbanView = document.getElementById('kanbanView');
|
||||
var tableBtn = document.getElementById('viewTableBtn');
|
||||
var kanbanBtn = document.getElementById('viewKanbanBtn');
|
||||
var groupBtn = document.getElementById('groupToggleBtn');
|
||||
|
||||
if (mode === 'kanban') {
|
||||
tableView.classList.add('d-none');
|
||||
kanbanView.classList.remove('d-none');
|
||||
tableBtn.classList.remove('active', 'btn-secondary');
|
||||
tableBtn.classList.add('btn-outline-secondary');
|
||||
kanbanBtn.classList.remove('btn-outline-secondary');
|
||||
kanbanBtn.classList.add('active', 'btn-secondary');
|
||||
groupBtn.classList.add('d-none');
|
||||
buildKanban();
|
||||
} else {
|
||||
kanbanView.classList.add('d-none');
|
||||
tableView.classList.remove('d-none');
|
||||
kanbanBtn.classList.remove('active', 'btn-secondary');
|
||||
kanbanBtn.classList.add('btn-outline-secondary');
|
||||
tableBtn.classList.remove('btn-outline-secondary');
|
||||
tableBtn.classList.add('active', 'btn-secondary');
|
||||
groupBtn.classList.remove('d-none');
|
||||
if (_grouped) applyGrouping();
|
||||
}
|
||||
}
|
||||
window.setView = setView;
|
||||
|
||||
function buildKanban() {
|
||||
var cols = { overdue: [], today: [], week: [], later: [] };
|
||||
var now = new Date();
|
||||
var startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
var endOfDay = new Date(startOfDay.getTime() + 86400000);
|
||||
var endOfWeek = new Date(startOfDay.getTime() + 7 * 86400000);
|
||||
|
||||
getRows().forEach(function (row) {
|
||||
if (row.dataset.completed === 'true') return;
|
||||
var ts = parseInt(row.dataset.dueTs) * 1000;
|
||||
var due = new Date(ts);
|
||||
var bucket = due < now ? 'overdue' : due < endOfDay ? 'today' : due < endOfWeek ? 'week' : 'later';
|
||||
cols[bucket].push({
|
||||
id: row.dataset.id,
|
||||
title: row.dataset.title,
|
||||
subject: row.dataset.subject,
|
||||
priority: row.dataset.priority,
|
||||
pinned: row.dataset.pinned === 'true',
|
||||
dueTs: ts
|
||||
});
|
||||
});
|
||||
|
||||
var priorityColor = { high: 'danger', medium: 'warning', low: 'secondary' };
|
||||
var priorityLabel = { high: '高', medium: '中', low: '低' };
|
||||
var priorityText = { high: 'white', medium: 'dark', low: 'white' };
|
||||
|
||||
function renderCol(key, colId, countId) {
|
||||
var el = document.getElementById(colId);
|
||||
var cntEl = document.getElementById(countId);
|
||||
el.innerHTML = '';
|
||||
cntEl.textContent = cols[key].length;
|
||||
if (!cols[key].length) {
|
||||
el.innerHTML = '<p class="text-muted small text-center mt-3">なし</p>';
|
||||
return;
|
||||
}
|
||||
var csrf = (document.getElementById('_csrf_global') || {}).value || '';
|
||||
cols[key].forEach(function (a) {
|
||||
var due = new Date(a.dueTs);
|
||||
var dateStr = due.getFullYear() + '/' + String(due.getMonth() + 1).padStart(2, '0') + '/' +
|
||||
String(due.getDate()).padStart(2, '0') + ' ' +
|
||||
String(due.getHours()).padStart(2, '0') + ':' + String(due.getMinutes()).padStart(2, '0');
|
||||
var toggleForm = document.querySelector('form[data-row-id="' + a.id + '"]');
|
||||
var toggleAction = toggleForm ? XSS.sanitizeUrl(toggleForm.action) : '#';
|
||||
var pc = priorityColor[a.priority] || 'secondary';
|
||||
var pl = priorityLabel[a.priority] || a.priority;
|
||||
var pt = priorityText[a.priority] || 'white';
|
||||
var card = document.createElement('div');
|
||||
card.className = 'kanban-card' + (a.pinned ? ' row-pinned' : '');
|
||||
card.innerHTML =
|
||||
'<div class="d-flex justify-content-between align-items-start mb-1">' +
|
||||
'<div class="fw-bold" style="font-size:0.85rem;word-break:break-all;">' + XSS.escapeHtml(a.title) + '</div>' +
|
||||
'<form action="' + toggleAction + '" method="POST" class="ms-1 flex-shrink-0">' +
|
||||
'<input type="hidden" name="_csrf" value="' + XSS.escapeHtml(csrf) + '">' +
|
||||
'<button type="submit" class="btn btn-sm btn-outline-success py-0 px-1" aria-label="完了にする"><i class="bi bi-check" aria-hidden="true"></i></button>' +
|
||||
'</form></div>' +
|
||||
'<div class="d-flex gap-1 flex-wrap">' +
|
||||
(a.subject ? (function() {
|
||||
var c = subjectColorFor(a.subject);
|
||||
var bg = c ? c.bg : '#6c757d';
|
||||
return '<span class="badge" style="font-size:0.7rem;background-color:' + bg + ';color:#fff;">' + XSS.escapeHtml(a.subject) + '</span>';
|
||||
})() : '') +
|
||||
'<span class="badge bg-' + pc + ' text-' + pt + '" style="font-size:0.7rem;">' + pl + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="text-muted mt-1" style="font-size:0.75rem;">' + XSS.escapeHtml(dateStr) + '</div>' +
|
||||
(a.pinned ? '<div class="text-warning" style="font-size:0.7rem;"><i class="bi bi-pin-fill"></i> ピン留め</div>' : '');
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
renderCol('overdue', 'kb-overdue', 'kb-count-overdue');
|
||||
renderCol('today', 'kb-today', 'kb-count-today');
|
||||
renderCol('week', 'kb-week', 'kb-count-week');
|
||||
renderCol('later', 'kb-later', 'kb-count-later');
|
||||
}
|
||||
|
||||
function applyGrouping() {
|
||||
removeGrouping();
|
||||
var rows = getRows();
|
||||
if (!rows.length) return;
|
||||
var groups = {};
|
||||
var order = [];
|
||||
rows.forEach(function (row) {
|
||||
var subj = row.dataset.subject || '(科目なし)';
|
||||
if (!groups[subj]) { groups[subj] = []; order.push(subj); }
|
||||
groups[subj].push(row);
|
||||
});
|
||||
if (order.length <= 1) return;
|
||||
var tbody = rows[0].closest('tbody');
|
||||
var theadRow = rows[0].closest('table').querySelector('thead tr');
|
||||
var colCount = theadRow ? theadRow.children.length : 8;
|
||||
order.forEach(function (subj) {
|
||||
var groupRows = groups[subj];
|
||||
var headerRow = document.createElement('tr');
|
||||
headerRow.className = 'subject-group-row';
|
||||
headerRow.dataset.group = subj;
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = colCount;
|
||||
td.innerHTML = '<i class="bi bi-chevron-down me-1"></i>' + XSS.escapeHtml(subj) +
|
||||
' <span class="badge bg-secondary ms-1">' + groupRows.length + '</span>';
|
||||
headerRow.appendChild(td);
|
||||
tbody.insertBefore(headerRow, groupRows[0]);
|
||||
headerRow.addEventListener('click', function () {
|
||||
var collapsed = headerRow.classList.toggle('collapsed');
|
||||
headerRow.querySelector('i').className = collapsed ? 'bi bi-chevron-right me-1' : 'bi bi-chevron-down me-1';
|
||||
groupRows.forEach(function (r) { r.style.display = collapsed ? 'none' : ''; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeGrouping() {
|
||||
document.querySelectorAll('.subject-group-row').forEach(function (r) { r.remove(); });
|
||||
getRows().forEach(function (r) { r.style.display = ''; });
|
||||
}
|
||||
|
||||
function toggleGrouping() {
|
||||
_grouped = !_grouped;
|
||||
localStorage.setItem('grouped', _grouped);
|
||||
var btn = document.getElementById('groupToggleBtn');
|
||||
var text = document.getElementById('groupBtnText');
|
||||
if (_grouped) {
|
||||
applyGrouping();
|
||||
btn.classList.add('btn-secondary', 'text-white');
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
text.textContent = 'グループ解除';
|
||||
} else {
|
||||
removeGrouping();
|
||||
btn.classList.remove('btn-secondary', 'text-white');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
text.textContent = 'グループ化';
|
||||
}
|
||||
}
|
||||
window.toggleGrouping = toggleGrouping;
|
||||
|
||||
function updateBulkBar() {
|
||||
var checked = document.querySelectorAll('.row-check:checked');
|
||||
var bar = document.getElementById('bulkBar');
|
||||
var countEl = document.getElementById('bulkCount');
|
||||
if (checked.length > 0) {
|
||||
bar.classList.remove('d-none');
|
||||
countEl.textContent = checked.length + '件選択中';
|
||||
} else {
|
||||
bar.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckedIDs() {
|
||||
return Array.from(document.querySelectorAll('.row-check:checked')).map(function (c) { return c.value; });
|
||||
}
|
||||
|
||||
function submitBulkComplete() {
|
||||
var ids = getCheckedIDs();
|
||||
if (!ids.length) return;
|
||||
var form = document.getElementById('bulkCompleteForm');
|
||||
ids.forEach(function (id) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'ids'; inp.value = id;
|
||||
form.appendChild(inp);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
window.submitBulkComplete = submitBulkComplete;
|
||||
|
||||
function confirmBulkDelete() {
|
||||
var ids = getCheckedIDs();
|
||||
if (!ids.length) return;
|
||||
|
||||
var recurringMap = {};
|
||||
ids.forEach(function (id) {
|
||||
var row = document.querySelector('.assignment-row[data-id="' + id + '"]');
|
||||
if (row && row.dataset.recurringId) {
|
||||
var rid = row.dataset.recurringId;
|
||||
if (!recurringMap[rid]) {
|
||||
recurringMap[rid] = { title: row.dataset.title, count: 0 };
|
||||
}
|
||||
recurringMap[rid].count++;
|
||||
}
|
||||
});
|
||||
|
||||
var recurringKeys = Object.keys(recurringMap);
|
||||
|
||||
if (recurringKeys.length > 0) {
|
||||
var list = document.getElementById('bulkDeleteRecurringList');
|
||||
list.innerHTML = '';
|
||||
recurringKeys.forEach(function (rid) {
|
||||
var item = recurringMap[rid];
|
||||
var li = document.createElement('li');
|
||||
li.className = 'list-group-item py-2 px-2 small';
|
||||
li.innerHTML = '<i class="bi bi-repeat text-info me-2" aria-hidden="true"></i>' +
|
||||
XSS.escapeHtml(item.title) +
|
||||
(item.count > 1 ? ' <span class="badge bg-secondary ms-1">' + item.count + '件</span>' : '');
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
var modalEl = document.getElementById('bulkDeleteRecurringModal');
|
||||
var modal = new bootstrap.Modal(modalEl);
|
||||
|
||||
document.getElementById('bulkDeleteOnlyBtn').onclick = function () {
|
||||
modal.hide();
|
||||
submitBulkDeleteForm(ids, false);
|
||||
};
|
||||
document.getElementById('bulkDeleteWithRecurringBtn').onclick = function () {
|
||||
modal.hide();
|
||||
submitBulkDeleteForm(ids, true);
|
||||
};
|
||||
|
||||
modal.show();
|
||||
} else {
|
||||
showConfirmModal(ids.length + '件の課題を削除しますか?', function () {
|
||||
submitBulkDeleteForm(ids, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
window.confirmBulkDelete = confirmBulkDelete;
|
||||
|
||||
function submitBulkDeleteForm(ids, deleteRecurring) {
|
||||
var form = document.getElementById('bulkDeleteForm');
|
||||
form.querySelectorAll('input[name="ids"], input[name="delete_recurring"]').forEach(function (inp) { inp.remove(); });
|
||||
ids.forEach(function (id) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'ids'; inp.value = id;
|
||||
form.appendChild(inp);
|
||||
});
|
||||
if (deleteRecurring) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'delete_recurring'; inp.value = 'true';
|
||||
form.appendChild(inp);
|
||||
}
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
document.querySelectorAll('.row-check, #selectAll').forEach(function (c) { c.checked = false; });
|
||||
updateBulkBar();
|
||||
}
|
||||
window.clearSelection = clearSelection;
|
||||
|
||||
function moveFocus(delta) {
|
||||
var rows = getRows().filter(function (r) { return r.style.display !== 'none'; });
|
||||
if (!rows.length) return;
|
||||
rows.forEach(function (r) { r.classList.remove('kb-focus'); });
|
||||
_kbFocusIndex = Math.max(0, Math.min(rows.length - 1, _kbFocusIndex + delta));
|
||||
rows[_kbFocusIndex].classList.add('kb-focus');
|
||||
rows[_kbFocusIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function toggleFocused() {
|
||||
var rows = getRows().filter(function (r) { return r.style.display !== 'none'; });
|
||||
if (_kbFocusIndex < 0 || _kbFocusIndex >= rows.length) return;
|
||||
var form = rows[_kbFocusIndex].querySelector('form[data-row-id]');
|
||||
if (form) form.submit();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!document.getElementById('tableView')) return;
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case '/':
|
||||
e.preventDefault();
|
||||
var s = document.getElementById('searchInput');
|
||||
if (s) s.focus();
|
||||
break;
|
||||
case 'j': moveFocus(1); break;
|
||||
case 'k': moveFocus(-1); break;
|
||||
case 'x': toggleFocused(); break;
|
||||
case 'n':
|
||||
if (!document.activeElement || document.activeElement === document.body) {
|
||||
window.location.href = '/assignments/new';
|
||||
}
|
||||
break;
|
||||
case 'Escape': clearSelection(); break;
|
||||
}
|
||||
});
|
||||
|
||||
var selectAll = document.getElementById('selectAll');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', function () {
|
||||
document.querySelectorAll('.row-check').forEach(function (c) { c.checked = selectAll.checked; });
|
||||
updateBulkBar();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.row-check').forEach(function (c) {
|
||||
c.addEventListener('change', function () {
|
||||
var all = document.querySelectorAll('.row-check');
|
||||
var checked = document.querySelectorAll('.row-check:checked');
|
||||
if (selectAll) selectAll.checked = all.length === checked.length;
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
|
||||
var recurringModal = document.getElementById('recurringModal');
|
||||
if (recurringModal) {
|
||||
recurringModal.addEventListener('show.bs.modal', function (event) {
|
||||
var button = event.relatedTarget;
|
||||
var id = button.getAttribute('data-recurring-id');
|
||||
var title = button.getAttribute('data-recurring-title');
|
||||
var type = button.getAttribute('data-recurring-type');
|
||||
var isActive = button.getAttribute('data-recurring-active') === 'true';
|
||||
document.getElementById('recurringModalTitle').textContent = title;
|
||||
document.getElementById('recurringStopForm').action = '/recurring/' + id + '/stop';
|
||||
document.getElementById('recurringEditBtn').href = '/recurring/' + id + '/edit';
|
||||
var typeLabels = { daily: '毎日', weekly: '毎週', monthly: '毎月', unknown: '(不明)' };
|
||||
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
|
||||
var statusEl = document.getElementById('recurringStatus');
|
||||
if (isActive) {
|
||||
statusEl.innerHTML = '<span class="badge bg-success">有効</span>';
|
||||
document.getElementById('recurringStopBtn').style.display = 'inline-block';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="badge bg-secondary">停止中</span>';
|
||||
document.getElementById('recurringStopBtn').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (localStorage.getItem('countdownHidden') === 'true') {
|
||||
document.querySelectorAll('.countdown-col').forEach(function (col) { col.style.display = 'none'; });
|
||||
var btn = document.getElementById('toggleCountdownBtn');
|
||||
var btnText = document.getElementById('countdownBtnText');
|
||||
if (btnText) btnText.textContent = '残り表示';
|
||||
if (btn) btn.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
|
||||
var gBtn = document.getElementById('groupToggleBtn');
|
||||
var gText = document.getElementById('groupBtnText');
|
||||
if (_grouped && gBtn) {
|
||||
gBtn.classList.add('btn-secondary', 'text-white');
|
||||
gBtn.classList.remove('btn-outline-secondary');
|
||||
if (gText) gText.textContent = 'グループ解除';
|
||||
}
|
||||
|
||||
window.showDeleteRecurringModal = function (assignmentId, recurringId) {
|
||||
var modal = new bootstrap.Modal(document.getElementById('deleteRecurringModal'));
|
||||
document.getElementById('deleteOnlyForm').action = '/assignments/' + assignmentId + '/delete';
|
||||
document.getElementById('deleteAndStopForm').action = '/assignments/' + assignmentId + '/delete?stop_recurring=' + recurringId;
|
||||
modal.show();
|
||||
};
|
||||
|
||||
setView(_view);
|
||||
updateCountdowns();
|
||||
}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1 class="mb-4"><i class="bi bi-key me-2"></i>APIキー管理</h1>
|
||||
<h1 class="mb-4"><i class="bi bi-key me-2" aria-hidden="true"></i>APIキー管理</h1>
|
||||
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
{{if .error}}<div class="alert alert-danger" role="alert">{{.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>
|
||||
<div class="alert alert-success" role="status">
|
||||
<h5 class="alert-heading"><i class="bi bi-check-circle me-2" aria-hidden="true"></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>
|
||||
<code class="flex-grow-1 bg-dark text-light p-2 rounded me-2" id="newApiKey" aria-label="APIキー">{{.newKey}}</code>
|
||||
<button class="btn btn-outline-secondary" id="copyKeyBtn" aria-label="APIキーをコピー">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plus-circle me-2"></i>新規APIキー作成
|
||||
<i class="bi bi-plus-circle me-2" aria-hidden="true"></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>
|
||||
<label for="keyName" class="visually-hidden">キー名</label>
|
||||
<input type="text" class="form-control" id="keyName" 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>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus me-1" aria-hidden="true"></i>作成</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -37,31 +40,32 @@
|
||||
|
||||
{{if .apiKeys}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover" aria-label="APIキー一覧">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>キー名</th>
|
||||
<th>作成者</th>
|
||||
<th>最終使用</th>
|
||||
<th>作成日</th>
|
||||
<th style="width: 100px">操作</th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">キー名</th>
|
||||
<th scope="col">作成者</th>
|
||||
<th scope="col">最終使用</th>
|
||||
<th scope="col">作成日</th>
|
||||
<th scope="col" 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><i class="bi bi-key me-1" aria-hidden="true"></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キーを削除しますか?')">
|
||||
data-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>
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" aria-label="{{.Name}}を削除">
|
||||
<i class="bi bi-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -71,7 +75,7 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-key display-1 text-muted"></i>
|
||||
<i class="bi bi-key display-1 text-muted" aria-hidden="true"></i>
|
||||
<h3 class="mt-3">APIキーがありません</h3>
|
||||
<p class="text-muted">上のフォームから新しいAPIキーを作成してください。</p>
|
||||
</div>
|
||||
@@ -79,12 +83,11 @@
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>API使用方法
|
||||
<i class="bi bi-info-circle me-2" aria-hidden="true"></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>
|
||||
<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>
|
||||
@@ -110,10 +113,19 @@
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function copyKey() {
|
||||
const key = document.getElementById('newApiKey').innerText;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
alert('APIキーをコピーしました');
|
||||
var copyKeyBtn = document.getElementById('copyKeyBtn');
|
||||
if (copyKeyBtn) {
|
||||
copyKeyBtn.addEventListener('click', function() {
|
||||
var key = document.getElementById('newApiKey').textContent.trim();
|
||||
if (!navigator.clipboard) {
|
||||
showCopyFeedback('クリップボードがサポートされていません。手動でコピーしてください。');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(key).then(function() {
|
||||
showCopyFeedback('APIキーをコピーしました');
|
||||
}).catch(function(err) {
|
||||
showCopyFeedback('コピーに失敗しました: ' + (err.message || '不明なエラー'));
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,55 +1,68 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1 class="mb-4"><i class="bi bi-people me-2"></i>ユーザー管理</h1>
|
||||
<h1 class="mb-4"><i class="bi bi-people me-2" aria-hidden="true"></i>ユーザー管理</h1>
|
||||
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
|
||||
|
||||
{{if .users}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover custom-table" aria-label="ユーザー一覧">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名前</th>
|
||||
<th>メールアドレス</th>
|
||||
<th>ロール</th>
|
||||
<th>登録日</th>
|
||||
<th style="width: 200px">操作</th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">名前</th>
|
||||
<th scope="col">メールアドレス</th>
|
||||
<th scope="col">ロール</th>
|
||||
<th scope="col">登録日</th>
|
||||
<th scope="col" style="width: 200px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .users}}
|
||||
<tr {{if eq .ID $.currentUserID}}class="table-primary" {{end}}>
|
||||
<tr class="user-row {{if eq .ID $.currentUserID}}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>
|
||||
{{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('このユーザーを削除しますか?')">
|
||||
<form action="/admin/users/{{.ID}}/role" method="POST" class="d-inline"
|
||||
data-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>
|
||||
<input type="hidden" name="role" value="user">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" aria-label="{{.Name}}をユーザーに降格">
|
||||
<i class="bi bi-arrow-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}<span class="text-muted">-</span>{{end}}
|
||||
{{else}}
|
||||
<form action="/admin/users/{{.ID}}/role" method="POST" class="d-inline"
|
||||
data-confirm="このユーザーを管理者に昇格しますか?">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<input type="hidden" name="role" value="admin">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" aria-label="{{.Name}}を管理者に昇格">
|
||||
<i class="bi bi-arrow-up" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form action="/admin/users/{{.ID}}/delete" method="POST" class="d-inline"
|
||||
data-confirm="このユーザーを削除しますか?">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" aria-label="{{.Name}}を削除">
|
||||
<i class="bi bi-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="text-muted">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -58,7 +71,7 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-people display-1 text-muted"></i>
|
||||
<i class="bi bi-people display-1 text-muted" aria-hidden="true"></i>
|
||||
<h3 class="mt-3">ユーザーがいません</h3>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -5,85 +5,76 @@
|
||||
<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>
|
||||
<h5 class="mb-0"><i class="bi bi-pencil me-2" aria-hidden="true"></i>課題編集</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
{{if .error}}<div class="alert alert-danger" role="alert">{{.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>
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger" aria-hidden="true">*</span><span class="visually-hidden">(必須)</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}}">
|
||||
<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>
|
||||
<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>
|
||||
<label for="due_date" class="form-label">提出期限(ガチ期限) <span class="text-danger" aria-hidden="true">*</span><span class="visually-hidden">(必須)</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="soft_due_date" class="form-label">自分の期限 <span class="badge bg-secondary">任意</span></label>
|
||||
<input type="datetime-local" class="form-control" id="soft_due_date" name="soft_due_date" value="{{if .assignment.SoftDueDate}}{{formatDateInput .assignment.SoftDueDate}}{{end}}">
|
||||
<div class="form-text small text-muted">鬼督促はこの期限を基準に通知します。未指定の場合は提出期限の2日前になります。</div>
|
||||
</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>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{.assignment.Description}}</textarea>
|
||||
</div>
|
||||
<!-- 通知設定 -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body py-2">
|
||||
<h6 class="mb-2"><i class="bi bi-bell me-1"></i>通知設定</h6>
|
||||
<!-- 督促通知 -->
|
||||
<h6 class="mb-2"><i class="bi bi-bell me-1" aria-hidden="true"></i>通知設定</h6>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="urgent_reminder_enabled"
|
||||
name="urgent_reminder_enabled" {{if
|
||||
.assignment.UrgentReminderEnabled}}checked{{end}}>
|
||||
<input class="form-check-input" type="checkbox" id="urgent_reminder_enabled" name="urgent_reminder_enabled" {{if .assignment.UrgentReminderEnabled}}checked{{end}}>
|
||||
<label class="form-check-label" for="urgent_reminder_enabled">
|
||||
督促通知(期限3時間前から繰り返し通知)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text small mb-2">
|
||||
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
||||
重要度により通知間隔が変わります:高=10分ごと、中=30分ごと、低=1時間ごと
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
||||
name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}}
|
||||
onchange="toggleReminderDate(this)">
|
||||
<input class="form-check-input" type="checkbox" id="reminder_enabled" name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}} onchange="toggleReminderDate(this)">
|
||||
<label class="form-check-label" for="reminder_enabled">
|
||||
リマインダー(指定日時に通知)
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2" id="reminder_at_group"
|
||||
style="display: {{if .assignment.ReminderEnabled}}block{{else}}none{{end}};">
|
||||
<div class="mt-2" id="reminder_at_group" style="display: {{if .assignment.ReminderEnabled}}block{{else}}none{{end}};">
|
||||
<label for="reminder_at" class="form-label small">通知日時</label>
|
||||
<input type="datetime-local" class="form-control form-control-sm" id="reminder_at"
|
||||
name="reminder_at"
|
||||
value="{{if .assignment.ReminderAt}}{{formatDateInput .assignment.ReminderAt}}{{end}}">
|
||||
<input type="datetime-local" class="form-control form-control-sm" id="reminder_at" name="reminder_at" value="{{if .assignment.ReminderAt}}{{formatDateInput .assignment.ReminderAt}}{{end}}">
|
||||
{{if .assignment.ReminderSent}}
|
||||
<div class="text-success small mt-1"><i class="bi bi-check-circle me-1"></i>通知送信済み</div>
|
||||
<div class="text-success small mt-1"><i class="bi bi-check-circle me-1" aria-hidden="true"></i>通知送信済み</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .recurring}}
|
||||
<!-- 繰り返し設定 -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
|
||||
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>繰り返し設定</h6>
|
||||
<a href="/recurring/{{.recurring.ID}}/edit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil me-1"></i>編集
|
||||
<i class="bi bi-pencil me-1" aria-hidden="true"></i>編集
|
||||
</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -110,7 +101,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>更新</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,191 +1,240 @@
|
||||
{{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>
|
||||
<input type="hidden" id="_csrf_global" value="{{.csrfToken}}">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-3 gap-2">
|
||||
<h4 class="mb-0 fw-bold"><i class="bi bi-list-task me-2" aria-hidden="true"></i>課題一覧</h4>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="表示切替">
|
||||
<button type="button" class="btn btn-outline-secondary" id="viewTableBtn" onclick="setView('table')" title="テーブル表示">
|
||||
<i class="bi bi-table" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="viewKanbanBtn" onclick="setView('kanban')" title="カンバン表示">
|
||||
<i class="bi bi-kanban" aria-hidden="true"></i>
|
||||
</button>
|
||||
</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 class="btn btn-sm btn-outline-secondary" id="groupToggleBtn" onclick="toggleGrouping()" title="科目でグループ化">
|
||||
<i class="bi bi-collection me-1" aria-hidden="true"></i><span id="groupBtnText" class="d-none d-sm-inline">グループ化</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary text-white" onclick="toggleCountdown()" id="toggleCountdownBtn" aria-pressed="false">
|
||||
<i class="bi bi-clock me-1" aria-hidden="true"></i><span id="countdownBtnText" class="d-none d-sm-inline">残り非表示</span>
|
||||
</button>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>新規登録
|
||||
<i class="bi bi-plus-lg me-1" aria-hidden="true"></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 {{if eq .filter "pending"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}">
|
||||
未完了
|
||||
</a>
|
||||
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "pending"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">未完了{{if gt .tabCounts.Pending 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "pending"}}bg-dark{{else}}bg-secondary{{end}}" style="font-size:0.7rem;">{{.tabCounts.Pending}}</span>{{end}}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_today"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}">
|
||||
今日が期限
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_today"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">今日が期限{{if gt .tabCounts.DueToday 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "due_today"}}bg-dark{{else}}bg-warning text-dark{{end}}" style="font-size:0.7rem;">{{.tabCounts.DueToday}}</span>{{end}}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_this_week"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}">
|
||||
今週が期限
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_this_week"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">今週が期限{{if gt .tabCounts.DueThisWeek 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "due_this_week"}}bg-dark{{else}}bg-secondary{{end}}" style="font-size:0.7rem;">{{.tabCounts.DueThisWeek}}</span>{{end}}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "completed"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}">
|
||||
完了済み
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "completed"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">完了済み{{if gt .tabCounts.Completed 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "completed"}}bg-dark{{else}}bg-secondary{{end}}" style="font-size:0.7rem;">{{.tabCounts.Completed}}</span>{{end}}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "overdue"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}">
|
||||
期限切れ
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "overdue"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">期限切れ{{if gt .tabCounts.Overdue 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "overdue"}}bg-dark{{else}}bg-danger{{end}}" style="font-size:0.7rem;">{{.tabCounts.Overdue}}</span>{{end}}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 text-muted"
|
||||
href="/recurring">
|
||||
繰り返し
|
||||
<li class="nav-item ms-auto" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 border-0 text-muted" href="/recurring">
|
||||
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>繰り返し管理
|
||||
</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">
|
||||
<form action="/assignments" method="GET" class="row g-2 mb-3 align-items-center" role="search">
|
||||
<input type="hidden" name="filter" value="{{.filter}}">
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4 col-12">
|
||||
<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}}">
|
||||
<span class="input-group-text bg-white border-end-0 text-muted"><i class="bi bi-search" aria-hidden="true"></i></span>
|
||||
<label for="searchInput" class="visually-hidden">課題を検索</label>
|
||||
<input type="text" class="form-control border-start-0 ps-0 bg-white" id="searchInput" 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>
|
||||
<div class="col-md-2 col-6">
|
||||
<label for="priorityFilter" class="visually-hidden">重要度</label>
|
||||
<select class="form-select form-select-sm bg-white" id="priorityFilter" 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="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 class="col-md-2 col-6">
|
||||
<label for="subjectFilter" class="visually-hidden">科目</label>
|
||||
<select class="form-select form-select-sm bg-white" id="subjectFilter" name="subject" onchange="this.form.submit()">
|
||||
<option value="">全科目</option>
|
||||
{{range .subjects}}<option value="{{.}}" {{if eq . $.subject}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 col-6">
|
||||
<label for="sortSelect" class="visually-hidden">並び順</label>
|
||||
<select class="form-select form-select-sm bg-white" id="sortSelect" name="sort" onchange="this.form.submit()">
|
||||
<option value="" {{if eq .sort "" }}selected{{end}}>期限昇順</option>
|
||||
<option value="due_desc" {{if eq .sort "due_desc" }}selected{{end}}>期限降順</option>
|
||||
<option value="priority" {{if eq .sort "priority" }}selected{{end}}>重要度</option>
|
||||
<option value="subject" {{if eq .sort "subject" }}selected{{end}}>科目</option>
|
||||
<option value="created_desc" {{if eq .sort "created_desc"}}selected{{end}}>登録日時</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 col-6">
|
||||
<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 id="bulkBar" class="d-none" role="toolbar" aria-label="一括操作">
|
||||
<span id="bulkCount" class="fw-bold small me-1"></span>
|
||||
<button type="button" class="btn btn-sm btn-light" onclick="submitBulkComplete()">
|
||||
<i class="bi bi-check-lg me-1" aria-hidden="true"></i>一括完了
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="confirmBulkDelete()">
|
||||
<i class="bi bi-trash me-1" aria-hidden="true"></i>一括削除
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-link text-white ms-auto p-0" onclick="clearSelection()" aria-label="選択解除">
|
||||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="bulkCompleteForm" action="/assignments/bulk-complete" method="POST" class="d-none">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
</form>
|
||||
<form id="bulkDeleteForm" action="/assignments/bulk-delete" method="POST" class="d-none">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
</form>
|
||||
|
||||
<div id="tableView">
|
||||
<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">
|
||||
<table class="table table-hover align-middle mb-0 custom-table" aria-label="課題一覧">
|
||||
<thead class="bg-secondary-subtle">
|
||||
<tr>
|
||||
<th style="width: 50px;" class="ps-3 text-center 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 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>
|
||||
<th scope="col" style="width: 36px;" class="ps-2 text-center">
|
||||
<input type="checkbox" id="selectAll" class="form-check-input" aria-label="全選択">
|
||||
</th>
|
||||
<th scope="col" style="width: 40px;" class="text-center text-dark fw-bold">状態</th>
|
||||
<th scope="col" style="width: 110px;" class="text-dark fw-bold sort-th">
|
||||
<a href="/assignments?filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{if eq .sort "subject"}}{{else}}subject{{end}}">
|
||||
科目{{if eq .sort "subject"}}<i class="bi bi-arrow-down-up" aria-hidden="true"></i>{{end}}
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" style="width: 70px;" class="text-dark fw-bold sort-th">
|
||||
<a href="/assignments?filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort=priority">
|
||||
重要度{{if eq .sort "priority"}}<i class="bi bi-arrow-down-up" aria-hidden="true"></i>{{end}}
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="text-dark fw-bold">タイトル</th>
|
||||
<th scope="col" style="width: 130px;" class="text-dark fw-bold sort-th">
|
||||
<a href="/assignments?filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{if eq .sort "due_desc"}}{{else}}due_desc{{end}}">
|
||||
期限{{if eq .sort ""}}↑{{else if eq .sort "due_desc"}}↓{{end}}
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||
<th scope="col" style="width: 100px;" 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">
|
||||
<tr class="assignment-row border-bottom {{if .IsPinned}}row-pinned{{end}}"
|
||||
data-due-ts="{{.DueDate.Unix}}"
|
||||
data-completed="{{.IsCompleted}}"
|
||||
data-id="{{.ID}}"
|
||||
data-subject="{{.Subject}}"
|
||||
data-priority="{{.Priority}}"
|
||||
data-pinned="{{.IsPinned}}"
|
||||
data-title="{{.Title}}"
|
||||
{{if .RecurringAssignmentID}}data-recurring-id="{{.RecurringAssignmentID}}"{{end}}>
|
||||
<td class="ps-2 text-center">
|
||||
<input type="checkbox" class="form-check-input row-check" value="{{.ID}}" aria-label="選択">
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{if .IsCompleted}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline" data-row-id="{{.ID}}">
|
||||
<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 type="submit" class="btn btn-link p-0 text-success text-decoration-none btn-touch" aria-label="未完了に戻す">
|
||||
<i class="bi bi-check-circle-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline" data-row-id="{{.ID}}">
|
||||
<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 type="submit" class="btn btn-link p-0 text-secondary text-decoration-none btn-touch" aria-label="完了にする">
|
||||
<i class="bi bi-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
||||
<td><span class="badge subject-badge border-0 fw-bold" data-subject="{{.Subject}}">{{.Subject}}</span></td>
|
||||
<td>
|
||||
{{if eq .Priority "high"}}
|
||||
<span class="badge bg-danger text-white border-0 fw-bold small">高</span>
|
||||
<span class="badge bg-danger text-white border-0 fw-bold small">高<span class="visually-hidden">(重要度:高)</span></span>
|
||||
{{else if eq .Priority "medium"}}
|
||||
<span class="badge bg-warning text-dark border-0 fw-bold small">中</span>
|
||||
<span class="badge bg-warning text-dark border-0 fw-bold small">中<span class="visually-hidden">(重要度:中)</span></span>
|
||||
{{else}}
|
||||
<span class="badge bg-dark text-white border-0 fw-bold small">低</span>
|
||||
<span class="badge bg-dark text-white border-0 fw-bold small">低<span class="visually-hidden">(重要度:低)</span></span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="fw-bold text-dark text-truncate" style="max-width: 280px;">{{.Title}}</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<div class="fw-bold text-dark title-clamp" title="{{.Title}}">{{.Title}}</div>
|
||||
{{if .RecurringAssignmentID}}
|
||||
<button type="button" class="btn btn-link p-0 ms-2 text-info" data-bs-toggle="modal"
|
||||
data-bs-target="#recurringModal" data-recurring-id="{{.RecurringAssignmentID}}"
|
||||
<button type="button" class="btn btn-link p-0 text-info btn-touch flex-shrink-0" data-bs-toggle="modal"
|
||||
data-bs-target="#recurringModal"
|
||||
data-recurring-id="{{.RecurringAssignmentID}}"
|
||||
data-assignment-id="{{.ID}}"
|
||||
data-recurring-title="{{.Title}}"
|
||||
data-recurring-type="{{if .RecurringAssignment}}{{.RecurringAssignment.RecurrenceType}}{{else}}unknown{{end}}"
|
||||
data-recurring-active="{{if .RecurringAssignment}}{{.RecurringAssignment.IsActive}}{{else}}true{{end}}"
|
||||
title="繰り返し課題">
|
||||
<i class="fa-solid fa-repeat"></i>
|
||||
aria-label="繰り返し設定を表示">
|
||||
<i class="bi bi-repeat" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .SoftDueDate}}<div class="small text-info mt-0"><i class="bi bi-clock-history me-1" aria-hidden="true"></i>{{.SoftDueDate.Format "01/02 15:04"}}</div>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
|
||||
</div>
|
||||
<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>
|
||||
<span class="countdown small fw-bold font-monospace text-dark" aria-live="off">...</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>
|
||||
<div class="d-flex justify-content-end gap-1">
|
||||
<form action="/assignments/{{.ID}}/pin" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 pin-btn btn-touch {{if .IsPinned}}pinned{{end}}" aria-label="{{if .IsPinned}}ピン解除{{else}}ピン留め{{end}}">
|
||||
<i class="bi bi-pin-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
<a href="/assignments/{{.ID}}/edit" class="text-primary text-decoration-none btn-touch d-inline-flex align-items-center" aria-label="編集">
|
||||
<i class="bi bi-pencil-fill" aria-hidden="true"></i>
|
||||
</a>
|
||||
{{if .RecurringAssignmentID}}
|
||||
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent"
|
||||
onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
{{if .RecurringAssignment}}
|
||||
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent btn-touch" onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})" aria-label="削除">
|
||||
<i class="bi bi-trash-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('削除しますか?');">
|
||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline" data-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 type="submit" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent btn-touch" aria-label="削除">
|
||||
<i class="bi bi-trash-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -194,8 +243,12 @@
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4 text-secondary fw-bold small">
|
||||
課題なし
|
||||
<td colspan="8" class="text-center py-5">
|
||||
<i class="bi bi-inbox display-4 text-muted" aria-hidden="true"></i>
|
||||
<p class="mt-2 mb-1 text-muted fw-bold">課題がありません</p>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary mt-1">
|
||||
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>課題を登録する
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -205,194 +258,104 @@
|
||||
</div>
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="card-footer bg-white border-top-0 py-2">
|
||||
<nav>
|
||||
<nav aria-label="ページナビゲーション">
|
||||
<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 class="page-link border-0 text-secondary" href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}" aria-label="前のページ">
|
||||
<i class="bi bi-chevron-left" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link border-0 text-dark fw-bold">{{.currentPage}} / {{.totalPages}}</span>
|
||||
<span class="page-link border-0 text-dark fw-bold" aria-current="page">{{.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 class="page-link border-0 text-secondary" href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}" aria-label="次のページ">
|
||||
<i class="bi bi-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateCountdowns() {
|
||||
const now = new Date();
|
||||
document.querySelectorAll('.assignment-row').forEach(row => {
|
||||
if (row.getAttribute('data-completed') === 'true') return;
|
||||
<div id="kanbanView" class="d-none">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger h-100">
|
||||
<div class="card-header bg-danger text-white py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-exclamation-triangle me-1" aria-hidden="true"></i>期限切れ</span>
|
||||
<span class="badge bg-white text-danger" id="kb-count-overdue">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-overdue"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning h-100">
|
||||
<div class="card-header bg-warning text-dark py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-calendar-event me-1" aria-hidden="true"></i>今日</span>
|
||||
<span class="badge bg-white text-warning" id="kb-count-today">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-today"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info h-100">
|
||||
<div class="card-header bg-info text-white py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-calendar-week me-1" aria-hidden="true"></i>今週</span>
|
||||
<span class="badge bg-white text-info" id="kb-count-week">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-week"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header bg-secondary text-white py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-calendar3 me-1" aria-hidden="true"></i>それ以降</span>
|
||||
<span class="badge bg-white text-secondary" id="kb-count-later">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-later"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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 = 'カウントダウン非表示中';
|
||||
}
|
||||
|
||||
// Recurring modal handler - wait for DOM to be ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const recurringModal = document.getElementById('recurringModal');
|
||||
if (recurringModal) {
|
||||
recurringModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const id = button.getAttribute('data-recurring-id');
|
||||
const assignmentId = button.getAttribute('data-assignment-id');
|
||||
const title = button.getAttribute('data-recurring-title');
|
||||
const type = button.getAttribute('data-recurring-type');
|
||||
const isActive = button.getAttribute('data-recurring-active') === 'true';
|
||||
|
||||
document.getElementById('recurringModalTitle').textContent = title;
|
||||
document.getElementById('recurringStopForm').action = '/recurring/' + id + '/stop';
|
||||
document.getElementById('recurringEditBtn').href = '/recurring/' + id + '/edit';
|
||||
|
||||
const typeLabels = {
|
||||
'daily': '毎日',
|
||||
'weekly': '毎週',
|
||||
'monthly': '毎月',
|
||||
'unknown': '(読み込み中...)'
|
||||
};
|
||||
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
|
||||
|
||||
|
||||
const statusEl = document.getElementById('recurringStatus');
|
||||
if (isActive) {
|
||||
statusEl.innerHTML = '<span class="badge bg-success">有効</span>';
|
||||
document.getElementById('recurringStopBtn').style.display = 'inline-block';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="badge bg-secondary">停止中</span>';
|
||||
document.getElementById('recurringStopBtn').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Recurring Modal -->
|
||||
<div class="modal fade" id="recurringModal" tabindex="-1">
|
||||
<div class="modal fade" id="recurringModal" tabindex="-1" aria-labelledby="recurringModalHeading" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-repeat me-2"></i>繰り返し課題</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h5 class="modal-title" id="recurringModalHeading"><i class="bi bi-repeat me-2" aria-hidden="true"></i>繰り返し課題</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="閉じる"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h6 id="recurringModalTitle" class="mb-3 fw-bold"></h6>
|
||||
<table class="table table-sm table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-muted" style="width: 100px;">繰り返し</th>
|
||||
<th class="text-muted" style="width: 100px;" scope="row">繰り返し</th>
|
||||
<td id="recurringTypeLabel">読み込み中...</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">状態</th>
|
||||
<th class="text-muted" scope="row">状態</th>
|
||||
<td id="recurringStatus">読み込み中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-info small mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<div class="alert alert-info small mb-0" role="note">
|
||||
<i class="bi bi-info-circle me-1" aria-hidden="true"></i>
|
||||
繰り返しを停止すると、今後新しい課題は自動作成されなくなります。既存の課題はそのまま残ります。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">閉じる</button>
|
||||
<a id="recurringEditBtn" href="#" class="btn btn-primary">
|
||||
<i class="bi bi-pencil me-1"></i>編集
|
||||
<i class="bi bi-pencil me-1" aria-hidden="true"></i>編集
|
||||
</a>
|
||||
<form id="recurringStopForm" method="POST" class="d-inline">
|
||||
<form id="recurringStopForm" method="POST" class="d-inline" data-confirm="繰り返しを停止しますか?">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" id="recurringStopBtn" class="btn btn-danger"
|
||||
onclick="return confirm('繰り返しを停止しますか?');">
|
||||
<i class="bi bi-stop-fill me-1"></i>停止
|
||||
<button type="submit" id="recurringStopBtn" class="btn btn-danger">
|
||||
<i class="bi bi-stop-fill me-1" aria-hidden="true"></i>停止
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -400,13 +363,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Recurring Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteRecurringModal" tabindex="-1">
|
||||
<div class="modal fade" id="deleteRecurringModal" tabindex="-1" aria-labelledby="deleteRecurringModalHeading" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>繰り返し課題の削除</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h5 class="modal-title" id="deleteRecurringModalHeading"><i class="bi bi-trash me-2" aria-hidden="true"></i>繰り返し課題の削除</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="閉じる"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>この課題は繰り返し設定に関連付けられています。</p>
|
||||
@@ -416,27 +378,38 @@
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
|
||||
<form id="deleteOnlyForm" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
課題のみ削除
|
||||
</button>
|
||||
<button type="submit" class="btn btn-outline-danger">課題のみ削除</button>
|
||||
</form>
|
||||
<form id="deleteAndStopForm" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
削除して繰り返しも削除
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger">削除して繰り返しも削除</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDeleteRecurringModal(assignmentId, recurringId) {
|
||||
var modal = new bootstrap.Modal(document.getElementById('deleteRecurringModal'));
|
||||
document.getElementById('deleteOnlyForm').action = '/assignments/' + assignmentId + '/delete';
|
||||
document.getElementById('deleteAndStopForm').action = '/assignments/' + assignmentId + '/delete?stop_recurring=' + recurringId;
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
<div class="modal fade" id="bulkDeleteRecurringModal" tabindex="-1" aria-labelledby="bulkDeleteRecurringModalHeading" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bulkDeleteRecurringModalHeading"><i class="bi bi-trash me-2" aria-hidden="true"></i>一括削除の確認</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="閉じる"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">選択した課題のうち、以下は繰り返し設定に関連付けられています。</p>
|
||||
<ul id="bulkDeleteRecurringList" class="list-group list-group-flush mb-3"></ul>
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1" aria-hidden="true"></i>
|
||||
繰り返し設定を削除すると、今後新しい課題は自動作成されなくなります。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
|
||||
<button type="button" class="btn btn-outline-danger" id="bulkDeleteOnlyBtn">課題のみ削除</button>
|
||||
<button type="button" class="btn btn-danger" id="bulkDeleteWithRecurringBtn">課題と繰り返しも削除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -5,193 +5,232 @@
|
||||
<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>
|
||||
<h5 class="mb-0"><i class="bi bi-plus-circle me-2" aria-hidden="true"></i>課題登録</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
{{if .error}}<div class="alert alert-danger" role="alert">{{.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>
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger" aria-hidden="true">*</span><span class="visually-hidden">(必須)</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="例: 数学、英語、情報">
|
||||
<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>
|
||||
<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>
|
||||
<label for="due_date" class="form-label">提出期限(ガチ期限) <span class="text-danger" aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
|
||||
<input type="datetime-local" class="form-control" id="due_date" name="due_date" value="{{.defaultDueDate}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="soft_due_date" class="form-label">自分の期限 <span class="badge bg-secondary">任意</span> <i class="bi bi-question-circle-fill text-muted" data-bs-toggle="tooltip" data-bs-placement="top" title="自分の期限とは、余裕を持って自分でこの期間までに提出するといった目標期限です。デフォルトでは2日前に設定されます。" aria-label="自分の期限の説明"></i></label>
|
||||
<input type="datetime-local" class="form-control" id="soft_due_date" name="soft_due_date" value="{{.defaultSoftDueDate}}">
|
||||
<div class="form-text small text-muted">鬼督促はこの期限を基準に通知します。</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">説明</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="3">{{.description}}</textarea>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{.description}}</textarea>
|
||||
</div>
|
||||
<!-- 通知設定 -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body py-2">
|
||||
<h6 class="mb-2"><i class="bi bi-bell me-1"></i>通知設定</h6>
|
||||
<!-- 督促通知 -->
|
||||
<h6 class="mb-2"><i class="bi bi-bell me-1" aria-hidden="true"></i>通知設定</h6>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="urgent_reminder_enabled"
|
||||
name="urgent_reminder_enabled" checked>
|
||||
<input class="form-check-input" type="checkbox" id="urgent_reminder_enabled" name="urgent_reminder_enabled" checked>
|
||||
<label class="form-check-label" for="urgent_reminder_enabled">
|
||||
督促通知(期限3時間前から繰り返し通知)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text small mb-2">
|
||||
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
||||
重要度により通知間隔が変わります:高=10分ごと、中=30分ごと、低=1時間ごと
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
||||
name="reminder_enabled" onchange="toggleReminderDate(this)">
|
||||
<input class="form-check-input" type="checkbox" id="reminder_enabled" name="reminder_enabled" onchange="toggleReminderDate(this)">
|
||||
<label class="form-check-label" for="reminder_enabled">
|
||||
リマインダー(指定日時に通知)
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2" id="reminder_at_group" style="display: none;">
|
||||
<label for="reminder_at" class="form-label small">通知日時</label>
|
||||
<input type="datetime-local" class="form-control form-control-sm" id="reminder_at"
|
||||
name="reminder_at">
|
||||
<input type="datetime-local" class="form-control form-control-sm" id="reminder_at" name="reminder_at">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header py-2" style="cursor: pointer;" data-bs-toggle="collapse"
|
||||
data-bs-target="#recurringSettings">
|
||||
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定 <i
|
||||
class="bi bi-chevron-down float-end"></i></h6>
|
||||
<div class="card-header py-2" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#recurringSettings" aria-expanded="false" aria-controls="recurringSettings">
|
||||
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>繰り返し設定 <i class="bi bi-chevron-down float-end" aria-hidden="true"></i></h6>
|
||||
</div>
|
||||
<div class="collapse" id="recurringSettings">
|
||||
<div class="card-body py-2">
|
||||
<div class="row mb-2">
|
||||
<div class="col-6">
|
||||
<div class="col-sm-6 col-12 mb-2 mb-sm-0">
|
||||
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_type"
|
||||
name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||
<select class="form-select form-select-sm" id="recurrence_type" name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||
<option value="none" selected>なし</option>
|
||||
<option value="daily">毎日</option>
|
||||
<option value="weekly">毎週</option>
|
||||
<option value="monthly">毎月</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6" id="interval_group" style="display: none;">
|
||||
<div class="col-sm-6 col-12" id="interval_group" style="display: none;">
|
||||
<label for="recurrence_interval" class="form-label small">間隔</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="recurrence_interval"
|
||||
name="recurrence_interval" value="1" min="1" max="12">
|
||||
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="1" min="1" max="12" onchange="updateLeadDaysMax()">
|
||||
<span class="input-group-text" id="interval_label">週</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="weekday_group" style="display: none;" class="mb-2">
|
||||
<div id="weekday_group" class="mb-2">
|
||||
<label class="form-label small">曜日</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
|
||||
value="0" {{if eq .currentWeekday 0}}checked{{end}}>
|
||||
<div class="btn-group btn-group-sm w-100" role="group" aria-label="曜日選択">
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" value="0" {{if eq .currentWeekday 0}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
|
||||
value="1" {{if eq .currentWeekday 1}}checked{{end}}>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" value="1" {{if eq .currentWeekday 1}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd1">月</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
|
||||
value="2" {{if eq .currentWeekday 2}}checked{{end}}>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2" value="2" {{if eq .currentWeekday 2}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd2">火</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
|
||||
value="3" {{if eq .currentWeekday 3}}checked{{end}}>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3" value="3" {{if eq .currentWeekday 3}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd3">水</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
|
||||
value="4" {{if eq .currentWeekday 4}}checked{{end}}>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4" value="4" {{if eq .currentWeekday 4}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd4">木</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
|
||||
value="5" {{if eq .currentWeekday 5}}checked{{end}}>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5" value="5" {{if eq .currentWeekday 5}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd5">金</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
|
||||
value="6" {{if eq .currentWeekday 6}}checked{{end}}>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6" value="6" {{if eq .currentWeekday 6}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd6">土</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="day_group" style="display: none;" class="mb-2">
|
||||
<div id="day_group" class="mb-2">
|
||||
<label for="recurrence_day" class="form-label small">日</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_day"
|
||||
name="recurrence_day">
|
||||
<select class="form-select form-select-sm" id="recurrence_day" name="recurrence_day">
|
||||
{{range $i := seq 1 31}}
|
||||
<option value="{{$i}}" {{if eq $.currentDay $i}}selected{{end}}>{{$i}}日</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div id="lead_days_group" style="display: none;" class="mb-2">
|
||||
<label class="form-label small">リストに追加するタイミング</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="input-group input-group-sm" style="width: 120px;">
|
||||
<input type="number" class="form-control" id="generation_lead_days" name="generation_lead_days" value="0" min="0" max="0">
|
||||
<span class="input-group-text">日前</span>
|
||||
</div>
|
||||
<input type="time" class="form-control form-control-sm" id="generation_lead_time" name="generation_lead_time" style="width: 110px;">
|
||||
</div>
|
||||
<div class="form-text small text-muted" id="lead_days_hint"></div>
|
||||
</div>
|
||||
<div id="end_group" style="display: none;">
|
||||
<label class="form-label small">終了条件</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_never"
|
||||
value="never" checked>
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_never" value="never" checked>
|
||||
<label class="form-check-label small" for="end_never">無期限</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_count"
|
||||
value="count">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_count" value="count">
|
||||
<label class="form-check-label small" for="end_count">回数</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_date"
|
||||
value="date">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_date" value="date">
|
||||
<label class="form-check-label small" for="end_date">終了日</label>
|
||||
</div>
|
||||
<div class="mt-1" id="end_count_group" style="display: none;">
|
||||
<input type="number" class="form-control form-control-sm" id="end_count_value"
|
||||
name="end_count" value="10" min="1" style="width: 100px;">
|
||||
<input type="number" class="form-control form-control-sm" id="end_count_value" name="end_count" value="10" min="1" style="max-width: 100px; width: 100%;">
|
||||
</div>
|
||||
<div class="mt-1" id="end_date_group" style="display: none;">
|
||||
<input type="date" class="form-control form-control-sm" id="end_date_value"
|
||||
name="end_date" style="width: 150px;">
|
||||
</div>
|
||||
<input type="date" class="form-control form-control-sm" id="end_date_value" name="end_date" style="max-width: 150px; width: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>登録</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Bootstrap tooltip初期化
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el) { new bootstrap.Tooltip(el); });
|
||||
|
||||
function toggleReminderDate(checkbox) {
|
||||
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
|
||||
}
|
||||
function getLeadDaysMax() {
|
||||
const type = document.getElementById('recurrence_type').value;
|
||||
const interval = parseInt(document.getElementById('recurrence_interval').value) || 1;
|
||||
if (type === 'daily') return interval;
|
||||
if (type === 'weekly') return interval * 7;
|
||||
if (type === 'monthly') return interval * 28;
|
||||
return 0;
|
||||
}
|
||||
function updateLeadDaysMax() {
|
||||
const max = getLeadDaysMax();
|
||||
const input = document.getElementById('generation_lead_days');
|
||||
input.max = max;
|
||||
if (parseInt(input.value) > max) input.value = max;
|
||||
const type = document.getElementById('recurrence_type').value;
|
||||
const labels = { daily: '日', weekly: '週', monthly: 'ヶ月' };
|
||||
const interval = document.getElementById('recurrence_interval').value;
|
||||
document.getElementById('lead_days_hint').textContent =
|
||||
max === 0 ? '間隔が1日のため、事前追加は設定できません' :
|
||||
`最大${max}日前まで指定可能(繰り返し間隔${interval}${labels[type]}以内)`;
|
||||
}
|
||||
function updateRecurrenceOptions() {
|
||||
const type = document.getElementById('recurrence_type').value;
|
||||
const isRecurring = type !== 'none';
|
||||
document.getElementById('interval_group').style.display = isRecurring ? 'block' : 'none';
|
||||
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
|
||||
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
|
||||
document.getElementById('lead_days_group').style.display = isRecurring ? 'block' : 'none';
|
||||
document.getElementById('end_group').style.display = isRecurring ? 'block' : 'none';
|
||||
const label = document.getElementById('interval_label');
|
||||
if (label) {
|
||||
if (type === 'daily') label.textContent = '日';
|
||||
else if (type === 'weekly') label.textContent = '週';
|
||||
else if (type === 'monthly') label.textContent = '月';
|
||||
}
|
||||
document.querySelectorAll('input[name="end_type"]').forEach(radio => {
|
||||
updateLeadDaysMax();
|
||||
}
|
||||
document.querySelectorAll('input[name="end_type"]').forEach(function(radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
document.getElementById('end_count_group').style.display = this.value === 'count' ? 'block' : 'none';
|
||||
document.getElementById('end_date_group').style.display = this.value === 'date' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
document.getElementById('recurringSettings').addEventListener('show.bs.collapse', function () {
|
||||
updateLeadDaysMax();
|
||||
});
|
||||
|
||||
// Auto-sync soft_due_date when due_date changes
|
||||
(function() {
|
||||
var dueDateInput = document.getElementById('due_date');
|
||||
var softDueDateInput = document.getElementById('soft_due_date');
|
||||
var userEditedSoftDue = false;
|
||||
softDueDateInput.addEventListener('input', function() { userEditedSoftDue = true; });
|
||||
dueDateInput.addEventListener('change', function() {
|
||||
if (userEditedSoftDue) return;
|
||||
if (!dueDateInput.value) return;
|
||||
var dueDate = new Date(dueDateInput.value);
|
||||
dueDate.setDate(dueDate.getDate() - 2);
|
||||
var y = dueDate.getFullYear();
|
||||
var m = String(dueDate.getMonth() + 1).padStart(2, '0');
|
||||
var d = String(dueDate.getDate()).padStart(2, '0');
|
||||
var h = String(dueDate.getHours()).padStart(2, '0');
|
||||
var min = String(dueDate.getMinutes()).padStart(2, '0');
|
||||
softDueDateInput.value = y + '-' + m + '-' + d + 'T' + h + ':' + min;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -2,111 +2,43 @@
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
.stat-card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.subject-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.table-progress {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
.stats-table th,
|
||||
.stats-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-table th:first-child,
|
||||
.stats-table td:first-child {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.stat-card { transition: transform 0.2s ease-in-out; }
|
||||
.stat-card:hover { transform: translateY(-5px); }
|
||||
.progress { height: 25px; }
|
||||
.progress-bar { font-size: 0.9rem; font-weight: 500; }
|
||||
.subject-row:hover { background-color: rgba(0, 0, 0, 0.02); }
|
||||
.table-progress { height: 20px; }
|
||||
.pagination-info { font-size: 0.875rem; }
|
||||
.stats-table { min-width: 700px; }
|
||||
.stats-table th, .stats-table td { white-space: nowrap; }
|
||||
.stats-table th:first-child, .stats-table td:first-child { min-width: 120px; }
|
||||
#shareCard {
|
||||
width: 600px;
|
||||
height: 315px;
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
width: 600px; height: 315px;
|
||||
position: fixed; left: -9999px; top: 0;
|
||||
background: linear-gradient(135deg, #005bea 0%, #00c6fb 100%);
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 2rem; color: white; border-radius: 12px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
#shareCard .card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#shareCard .rate-display {
|
||||
font-size: 5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#shareCard .card-title { font-size: 1.5rem; font-weight: bold; opacity: 0.9; }
|
||||
#shareCard .rate-display { font-size: 5rem; font-weight: bold; line-height: 1; margin: 1rem 0; }
|
||||
#shareCard .stats-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex; justify-content: space-around;
|
||||
background: rgba(255,255,255,0.1); border-radius: 8px; padding: 1rem;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
#shareCard .stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#shareCard .stat-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
#shareCard .stat-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
#shareCard .stat-item { text-align: center; }
|
||||
#shareCard .stat-label { font-size: 0.8rem; opacity: 0.8; display: block; margin-bottom: 0.2rem; }
|
||||
#shareCard .stat-value { font-size: 1.4rem; font-weight: bold; }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-bar-chart me-2"></i>統計</h1>
|
||||
<h1><i class="bi bi-bar-chart me-2" aria-hidden="true"></i>統計</h1>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
|
||||
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>課題一覧に戻る
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -114,35 +46,67 @@
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/statistics" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">科目</label>
|
||||
<select name="subject" class="form-select">
|
||||
<label for="subjectFilter" class="form-label">科目</label>
|
||||
<select id="subjectFilter" name="subject" class="form-select">
|
||||
<option value="">すべての科目</option>
|
||||
{{range .subjects}}
|
||||
<option value="{{.}}" {{if eq . $.selectedSubject}}selected{{end}}>{{.}}{{if index
|
||||
$.archivedSubjects .}} (アーカイブ済){{end}}</option>
|
||||
<option value="{{.}}" {{if eq . $.selectedSubject}}selected{{end}}>{{.}}{{if index $.archivedSubjects .}} (アーカイブ済){{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">登録日(開始)</label>
|
||||
<input type="date" name="from" class="form-control" value="{{.fromDate}}">
|
||||
<label for="fromDate" class="form-label">登録日(開始)</label>
|
||||
<input type="date" id="fromDate" name="from" class="form-control" value="{{.fromDate}}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">登録日(終了)</label>
|
||||
<input type="date" name="to" class="form-control" value="{{.toDate}}">
|
||||
<label for="toDate" class="form-label">登録日(終了)</label>
|
||||
<input type="date" id="toDate" name="to" class="form-control" value="{{.toDate}}">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="col-md-4 d-flex align-items-end mt-2 mt-md-0">
|
||||
<button type="submit" class="btn btn-primary me-2">
|
||||
<i class="bi bi-filter me-1"></i>絞り込み
|
||||
<i class="bi bi-filter me-1" aria-hidden="true"></i>絞り込み
|
||||
</button>
|
||||
<a href="/statistics" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i>リセット
|
||||
<i class="bi bi-x-lg me-1" aria-hidden="true"></i>リセット
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-download me-2" aria-hidden="true"></i>CSVエクスポート
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/assignments/export" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="csvSubject" class="form-label">科目</label>
|
||||
<select id="csvSubject" name="subject" class="form-select">
|
||||
<option value="">すべての科目</option>
|
||||
{{range .subjects}}
|
||||
<option value="{{.}}">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="csvFrom" class="form-label">提出期限(開始)</label>
|
||||
<input type="date" id="csvFrom" name="from" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="csvTo" class="form-label">提出期限(終了)</label>
|
||||
<input type="date" id="csvTo" name="to" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-file-earmark-spreadsheet me-1" aria-hidden="true"></i>CSVダウンロード
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<small class="text-muted mt-2 d-block">期間・科目を指定しない場合は全件をエクスポートします。提出期限を基準に絞り込みます。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card stat-card bg-primary text-white h-100">
|
||||
@@ -152,7 +116,7 @@
|
||||
<h6 class="text-white-50 mb-1">総課題数</h6>
|
||||
<h2 class="mb-0">{{.stats.TotalAssignments}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||
<i class="bi bi-list-task display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +129,7 @@
|
||||
<h6 class="text-white-50 mb-1">完了</h6>
|
||||
<h2 class="mb-0">{{.stats.CompletedAssignments}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-check-circle display-4 opacity-50"></i>
|
||||
<i class="bi bi-check-circle display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +142,7 @@
|
||||
<h6 class="text-dark-50 mb-1">未完了</h6>
|
||||
<h2 class="mb-0">{{.stats.PendingAssignments}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-hourglass-split display-4 opacity-50"></i>
|
||||
<i class="bi bi-hourglass-split display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,7 +155,7 @@
|
||||
<h6 class="text-white-50 mb-1">期限切れ</h6>
|
||||
<h2 class="mb-0">{{.stats.OverdueAssignments}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||
<i class="bi bi-exclamation-triangle display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,38 +164,24 @@
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-clock-history me-2"></i>期限内完了率</span>
|
||||
<span><i class="bi bi-clock-history me-2" aria-hidden="true"></i>期限内完了率</span>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="generateShareImage()">
|
||||
<i class="bi bi-share me-1"></i>シェア
|
||||
<i class="bi bi-share me-1" aria-hidden="true"></i>シェア
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3 text-center mb-3 mb-md-0">
|
||||
<h1
|
||||
class="display-3 mb-0 {{if ge .stats.OnTimeCompletionRate 80.0}}text-success{{else if ge .stats.OnTimeCompletionRate 50.0}}text-warning{{else}}text-danger{{end}}">
|
||||
<p class="display-3 mb-0 {{if ge .stats.OnTimeCompletionRate 80.0}}text-success{{else if ge .stats.OnTimeCompletionRate 50.0}}text-warning{{else}}text-danger{{end}}" aria-label="期限内完了率 {{printf "%.1f" .stats.OnTimeCompletionRate}}パーセント">
|
||||
{{printf "%.1f" .stats.OnTimeCompletionRate}}%
|
||||
</h1>
|
||||
</p>
|
||||
<small class="text-muted">期限内完了率</small>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="progress">
|
||||
{{if ge .stats.OnTimeCompletionRate 80.0}}
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||
<div class="progress" role="progressbar" aria-label="期限内完了率" aria-valuenow="{{printf "%.0f" .stats.OnTimeCompletionRate}}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar {{if ge .stats.OnTimeCompletionRate 80.0}}bg-success{{else if ge .stats.OnTimeCompletionRate 50.0}}bg-warning{{else}}bg-danger{{end}}" style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
|
||||
</div>
|
||||
{{else if ge .stats.OnTimeCompletionRate 50.0}}
|
||||
<div class="progress-bar bg-warning" role="progressbar"
|
||||
style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="progress-bar bg-danger" role="progressbar"
|
||||
style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">完了した課題のうち、期限内に完了した割合を表示しています。</small>
|
||||
</div>
|
||||
@@ -241,22 +191,22 @@
|
||||
|
||||
<div class="card mb-4" id="activeSubjectsCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-collection me-2"></i>アクティブ科目</span>
|
||||
<span><i class="bi bi-collection me-2" aria-hidden="true"></i>アクティブ科目</span>
|
||||
<span class="badge bg-primary" id="activeCount">0</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 stats-table">
|
||||
<table class="table table-hover mb-0 stats-table" aria-label="アクティブ科目統計">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>科目</th>
|
||||
<th class="text-center">総数</th>
|
||||
<th class="text-center">完了</th>
|
||||
<th class="text-center">未完了</th>
|
||||
<th class="text-center">期限切れ</th>
|
||||
<th class="text-center">完了率</th>
|
||||
<th style="width: 150px;">進捗</th>
|
||||
<th class="text-center">操作</th>
|
||||
<th scope="col">科目</th>
|
||||
<th scope="col" class="text-center">総数</th>
|
||||
<th scope="col" class="text-center">完了</th>
|
||||
<th scope="col" class="text-center">未完了</th>
|
||||
<th scope="col" class="text-center">期限切れ</th>
|
||||
<th scope="col" class="text-center">完了率</th>
|
||||
<th scope="col" style="width: 150px;">進捗</th>
|
||||
<th scope="col" class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="activeSubjectsBody"></tbody>
|
||||
@@ -265,7 +215,7 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="pagination-info" id="activePageInfo"></span>
|
||||
<nav>
|
||||
<nav aria-label="アクティブ科目ページナビゲーション">
|
||||
<ul class="pagination pagination-sm mb-0" id="activePagination"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -273,22 +223,22 @@
|
||||
|
||||
<div class="card" id="archivedSubjectsCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-archive me-2"></i>アーカイブ済み科目</span>
|
||||
<span><i class="bi bi-archive me-2" aria-hidden="true"></i>アーカイブ済み科目</span>
|
||||
<span class="badge bg-secondary" id="archivedCount">0</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 stats-table">
|
||||
<table class="table table-hover mb-0 stats-table" aria-label="アーカイブ済み科目統計">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>科目</th>
|
||||
<th class="text-center">総数</th>
|
||||
<th class="text-center">完了</th>
|
||||
<th class="text-center">未完了</th>
|
||||
<th class="text-center">期限切れ</th>
|
||||
<th class="text-center">完了率</th>
|
||||
<th style="width: 150px;">進捗</th>
|
||||
<th class="text-center">操作</th>
|
||||
<th scope="col">科目</th>
|
||||
<th scope="col" class="text-center">総数</th>
|
||||
<th scope="col" class="text-center">完了</th>
|
||||
<th scope="col" class="text-center">未完了</th>
|
||||
<th scope="col" class="text-center">期限切れ</th>
|
||||
<th scope="col" class="text-center">完了率</th>
|
||||
<th scope="col" style="width: 150px;">進捗</th>
|
||||
<th scope="col" class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="archivedSubjectsBody"></tbody>
|
||||
@@ -297,7 +247,7 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="pagination-info" id="archivedPageInfo"></span>
|
||||
<nav>
|
||||
<nav aria-label="アーカイブ済み科目ページナビゲーション">
|
||||
<ul class="pagination pagination-sm mb-0" id="archivedPagination"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -305,27 +255,25 @@
|
||||
|
||||
<div class="card d-none" id="noSubjectsCard">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<i class="bi bi-inbox display-1 text-muted" aria-hidden="true"></i>
|
||||
<h4 class="mt-3">科目別の統計データがありません</h4>
|
||||
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="shareCard">
|
||||
<div id="shareCard" aria-hidden="true">
|
||||
<div>
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<i class="bi bi-journal-check me-2" style="font-size: 1.5rem;"></i>
|
||||
<span class="card-title">Super Homework Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div style="font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem; opacity: 0.9;">期限内完了率</div>
|
||||
<div class="rate-display" style="margin-top: 0;">
|
||||
{{printf "%.1f" .stats.OnTimeCompletionRate}}<span style="font-size: 2.5rem;">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">完了</span>
|
||||
@@ -342,31 +290,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<div class="modal fade" id="shareModal" tabindex="-1">
|
||||
<div class="modal fade" id="shareModal" tabindex="-1" aria-labelledby="shareModalHeading" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-share me-2"></i>シェア</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h5 class="modal-title" id="shareModalHeading"><i class="bi bi-share me-2" aria-hidden="true"></i>シェア</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="閉じる"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<div id="sharePreviewContainer" class="mb-3" style="max-width: 100%; overflow: hidden;">
|
||||
</div>
|
||||
<div id="sharePreviewContainer" class="mb-3" style="max-width: 100%; overflow: hidden;"></div>
|
||||
<p class="text-muted small mb-3">
|
||||
画像を保存またはコピーして、SNSに貼り付けてください。<br>
|
||||
<span class="text-danger"><i class="bi bi-info-circle me-1"></i>ブラウザの制限により、自動で画像は添付されません。</span>
|
||||
<span class="text-danger"><i class="bi bi-info-circle me-1" aria-hidden="true"></i>ブラウザの制限により、自動で画像は添付されません。</span>
|
||||
</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-primary" onclick="copyImageToClipboard(this)">
|
||||
<i class="bi bi-clipboard me-2"></i>画像をコピー
|
||||
<button class="btn btn-outline-primary" id="copyImageBtn">
|
||||
<i class="bi bi-clipboard me-2" aria-hidden="true"></i>画像をコピー
|
||||
</button>
|
||||
<a id="downloadLink" class="btn btn-outline-secondary" download="stats.png">
|
||||
<i class="bi bi-download me-2"></i>画像を保存
|
||||
<i class="bi bi-download me-2" aria-hidden="true"></i>画像を保存
|
||||
</a>
|
||||
<a id="twitterShareBtn" href="#" target="_blank" class="btn btn-dark"
|
||||
style="background-color: #000;">
|
||||
<i class="bi bi-twitter-x me-2"></i>Xでポストする
|
||||
<a id="twitterShareBtn" href="#" target="_blank" rel="noopener noreferrer" class="btn btn-dark" style="background-color: #000;">
|
||||
<i class="bi bi-twitter-x me-2" aria-hidden="true"></i>Xでポストする
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,20 +334,15 @@
|
||||
var archivedSubjects = subjects.filter(function (s) { return s.isArchived; });
|
||||
var activePage = 1;
|
||||
var archivedPage = 1;
|
||||
var _capturedCanvas = null;
|
||||
|
||||
// Share Functionality
|
||||
window.generateShareImage = function () {
|
||||
var card = document.getElementById('shareCard');
|
||||
// Ensure card is visible for rendering but off-screen
|
||||
card.style.display = 'flex';
|
||||
|
||||
html2canvas(card, {
|
||||
backgroundColor: null,
|
||||
scale: 2 // High resolution
|
||||
}).then(canvas => {
|
||||
html2canvas(card, { backgroundColor: null, scale: 2 }).then(function(canvas) {
|
||||
_capturedCanvas = canvas;
|
||||
var imgData = canvas.toDataURL('image/png');
|
||||
|
||||
// Set up preview
|
||||
var previewContainer = document.getElementById('sharePreviewContainer');
|
||||
previewContainer.innerHTML = '';
|
||||
var img = document.createElement('img');
|
||||
@@ -410,67 +350,51 @@
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.borderRadius = '8px';
|
||||
img.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||
img.alt = '統計シェア画像';
|
||||
previewContainer.appendChild(img);
|
||||
|
||||
// Set up download link
|
||||
var downloadLink = document.getElementById('downloadLink');
|
||||
downloadLink.href = imgData;
|
||||
document.getElementById('downloadLink').href = imgData;
|
||||
|
||||
// Set up Twitter button
|
||||
var text = '私の課題完了状況\n期限内完了率: {{printf "%.1f" .stats.OnTimeCompletionRate}}%\n#SuperHomeworkManager';
|
||||
var twitterBtn = document.getElementById('twitterShareBtn');
|
||||
twitterBtn.href = "https://twitter.com/intent/tweet?text=" + encodeURIComponent(text);
|
||||
document.getElementById('twitterShareBtn').href = 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text);
|
||||
|
||||
// Show modal
|
||||
var modal = new bootstrap.Modal(document.getElementById('shareModal'));
|
||||
modal.show();
|
||||
new bootstrap.Modal(document.getElementById('shareModal')).show();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to convert Data URL to Blob
|
||||
function dataURLtoBlob(dataurl) {
|
||||
var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
|
||||
bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new Blob([u8arr], { type: mime });
|
||||
var arr = dataurl.split(',');
|
||||
var mime = arr[0].match(/:(.*?);/)[1];
|
||||
var bstr = atob(arr[1]);
|
||||
var n = bstr.length;
|
||||
var u8 = new Uint8Array(n);
|
||||
while (n--) u8[n] = bstr.charCodeAt(n);
|
||||
return new Blob([u8], { type: mime });
|
||||
}
|
||||
|
||||
window.copyImageToClipboard = function (btn) {
|
||||
var canvas = document.querySelector('#sharePreviewContainer img');
|
||||
if (!canvas) return;
|
||||
document.getElementById('copyImageBtn').addEventListener('click', function() {
|
||||
var btn = this;
|
||||
var imgEl = document.querySelector('#sharePreviewContainer img');
|
||||
if (!imgEl) return;
|
||||
|
||||
if (!navigator.clipboard) {
|
||||
alert('このブラウザまたは環境(非HTTPS/非localhost)では、クリップボードへのコピー機能がサポートされていません。\n「画像を保存」を使用してください。');
|
||||
if (!navigator.clipboard || !window.ClipboardItem) {
|
||||
showCopyFeedback('このブラウザではクリップボードへの画像コピーがサポートされていません。「画像を保存」をご利用ください。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var blob = dataURLtoBlob(canvas.src);
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': blob
|
||||
})
|
||||
]).then(function () {
|
||||
var originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-check me-2"></i>コピーしました';
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.classList.remove('btn-success');
|
||||
var blob = dataURLtoBlob(imgEl.src);
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(function() {
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-check me-2" aria-hidden="true"></i>コピーしました';
|
||||
btn.classList.replace('btn-outline-primary', 'btn-success');
|
||||
setTimeout(function() {
|
||||
btn.innerHTML = orig;
|
||||
btn.classList.replace('btn-success', 'btn-outline-primary');
|
||||
}, 2000);
|
||||
}).catch(function (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
alert('画像のコピーに失敗しました。\nエラー: ' + err.message + '\n「画像を保存」を使用してください。');
|
||||
}).catch(function(err) {
|
||||
showCopyFeedback('画像のコピーに失敗しました。「画像を保存」をご利用ください。(' + (err.message || err) + ')');
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create blob: ', err);
|
||||
alert('画像データの生成に失敗しました: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function getRateClass(rate) {
|
||||
if (rate >= 80) return 'text-success';
|
||||
@@ -479,22 +403,22 @@
|
||||
}
|
||||
|
||||
function renderProgress(completed, pending, overdue, total) {
|
||||
if (total === 0) return '<div class="progress table-progress"></div>';
|
||||
if (total === 0) return '<div class="progress table-progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="進捗なし"></div>';
|
||||
var cP = (completed / total * 100).toFixed(1);
|
||||
var pP = (pending / total * 100).toFixed(1);
|
||||
var oP = (overdue / total * 100).toFixed(1);
|
||||
return '<div class="progress table-progress">' +
|
||||
return '<div class="progress table-progress" role="progressbar" aria-valuenow="' + cP + '" aria-valuemin="0" aria-valuemax="100" aria-label="完了' + cP + '%、未完了' + pP + '%、期限切れ' + oP + '%">' +
|
||||
'<div class="progress-bar bg-success" style="width:' + cP + '%" title="完了: ' + completed + '"></div>' +
|
||||
'<div class="progress-bar bg-warning" style="width:' + pP + '%" title="未完了: ' + pending + '"></div>' +
|
||||
'<div class="progress-bar bg-danger" style="width:' + oP + '%" title="期限切れ: ' + overdue + '"></div></div>';
|
||||
}
|
||||
|
||||
function renderRow(s, isArchived) {
|
||||
var action = isArchived ?
|
||||
'<form action="/statistics/unarchive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + s.subject + '"><button type="submit" class="btn btn-sm btn-outline-success" title="復元"><i class="bi bi-arrow-counterclockwise"></i></button></form>' :
|
||||
'<form action="/statistics/archive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + s.subject + '"><button type="submit" class="btn btn-sm btn-outline-secondary" title="アーカイブ"><i class="bi bi-archive"></i></button></form>';
|
||||
var action = isArchived
|
||||
? '<form action="/statistics/unarchive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + XSS.escapeHtml(s.subject) + '"><button type="submit" class="btn btn-sm btn-outline-success" aria-label="' + XSS.escapeHtml(s.subject) + 'を復元"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i></button></form>'
|
||||
: '<form action="/statistics/archive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + XSS.escapeHtml(s.subject) + '"><button type="submit" class="btn btn-sm btn-outline-secondary" aria-label="' + XSS.escapeHtml(s.subject) + 'をアーカイブ"><i class="bi bi-archive" aria-hidden="true"></i></button></form>';
|
||||
return '<tr class="subject-row">' +
|
||||
'<td><a href="/statistics?subject=' + encodeURIComponent(s.subject) + '" class="text-decoration-none"><i class="bi bi-folder me-1"></i>' + s.subject + '</a></td>' +
|
||||
'<td><a href="/statistics?subject=' + encodeURIComponent(s.subject) + '" class="text-decoration-none"><i class="bi bi-folder me-1" aria-hidden="true"></i>' + XSS.escapeHtml(s.subject) + '</a></td>' +
|
||||
'<td class="text-center">' + s.total + '</td>' +
|
||||
'<td class="text-center text-success">' + s.completed + '</td>' +
|
||||
'<td class="text-center text-warning">' + s.pending + '</td>' +
|
||||
@@ -510,7 +434,7 @@
|
||||
if (total <= 1) return;
|
||||
var prev = document.createElement('li');
|
||||
prev.className = 'page-item' + (page === 1 ? ' disabled' : '');
|
||||
prev.innerHTML = '<a class="page-link" href="#">«</a>';
|
||||
prev.innerHTML = '<a class="page-link" href="#" aria-label="前のページ">«</a>';
|
||||
if (page > 1) prev.onclick = function (e) { e.preventDefault(); cb(page - 1); };
|
||||
el.appendChild(prev);
|
||||
for (var i = 1; i <= total; i++) {
|
||||
@@ -522,7 +446,7 @@
|
||||
}
|
||||
var next = document.createElement('li');
|
||||
next.className = 'page-item' + (page === total ? ' disabled' : '');
|
||||
next.innerHTML = '<a class="page-link" href="#">»</a>';
|
||||
next.innerHTML = '<a class="page-link" href="#" aria-label="次のページ">»</a>';
|
||||
if (page < total) next.onclick = function (e) { e.preventDefault(); cb(page + 1); };
|
||||
el.appendChild(next);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "head"}}
|
||||
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
@@ -25,6 +31,28 @@
|
||||
<label for="password" class="form-label">パスワード</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
{{if .captchaEnabled}}
|
||||
{{if eq .captchaType "turnstile"}}
|
||||
<div class="mb-3 d-flex justify-content-center">
|
||||
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
|
||||
</div>
|
||||
{{else if eq .captchaType "image"}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">画像認証</label>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
|
||||
<input type="text" class="form-control" name="captcha_answer"
|
||||
placeholder="上の数字を入力" autocomplete="off" required>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
||||
</div>
|
||||
@@ -41,3 +69,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
{{if and .captchaEnabled (eq .captchaType "image")}}
|
||||
<script>
|
||||
function reloadCaptcha() {
|
||||
fetch('/captcha-new')
|
||||
.then(r => r.text())
|
||||
.then(id => {
|
||||
document.getElementById('captchaID').value = id;
|
||||
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
46
web/templates/auth/login_2fa.html
Normal file
46
web/templates/auth/login_2fa.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{{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-shield-lock display-4 text-primary"></i>
|
||||
<h2 class="mt-2">2段階認証</h2>
|
||||
<p class="text-muted small">認証アプリに表示されている6桁のコードを入力してください</p>
|
||||
</div>
|
||||
|
||||
{{if .error}}
|
||||
<div class="alert alert-danger">{{.error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login/2fa">
|
||||
{{.csrfField}}
|
||||
<div class="mb-4">
|
||||
<label for="totp_code" class="form-label">認証コード</label>
|
||||
<input type="text" class="form-control form-control-lg text-center"
|
||||
id="totp_code" name="totp_code"
|
||||
placeholder="000000"
|
||||
maxlength="6" pattern="[0-9]{6}"
|
||||
inputmode="numeric" autocomplete="one-time-code"
|
||||
autofocus required>
|
||||
<div class="form-text text-center">Google Authenticator などのアプリで確認</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-shield-check me-1"></i>確認
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login" class="text-muted small">別のアカウントでログイン</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,5 +1,11 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "head"}}
|
||||
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
@@ -28,14 +34,36 @@
|
||||
<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>
|
||||
minlength="8">
|
||||
<div class="form-text">8文字以上</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">
|
||||
required minlength="8">
|
||||
</div>
|
||||
|
||||
{{if .captchaEnabled}}
|
||||
{{if eq .captchaType "turnstile"}}
|
||||
<div class="mb-3 d-flex justify-content-center">
|
||||
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
|
||||
</div>
|
||||
{{else if eq .captchaType "image"}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">画像認証</label>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
|
||||
<input type="text" class="form-control" name="captcha_answer"
|
||||
placeholder="上の数字を入力" autocomplete="off" required>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
||||
</div>
|
||||
@@ -52,3 +80,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
{{if and .captchaEnabled (eq .captchaType "image")}}
|
||||
<script>
|
||||
function reloadCaptcha() {
|
||||
fetch('/captcha-new')
|
||||
.then(r => r.text())
|
||||
.then(id => {
|
||||
document.getElementById('captchaID').value = id;
|
||||
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -8,18 +8,16 @@
|
||||
<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="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" rel="stylesheet">
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
<style>
|
||||
.navbar-dark .navbar-nav .nav-link,
|
||||
.navbar-brand {
|
||||
color: #fff !important;
|
||||
color: #fff;
|
||||
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;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
</style>
|
||||
{{template "head" .}}
|
||||
@@ -27,53 +25,51 @@
|
||||
|
||||
<body>
|
||||
{{if .userName}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary" role="navigation" aria-label="メインナビゲーション">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<i class="bi bi-journal-check me-2"></i>Super Homework Manager
|
||||
<i class="bi bi-journal-check me-2" aria-hidden="true"></i>Super Homework Manager
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<button class="navbar-toggler" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="ナビゲーションを開く">
|
||||
<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>
|
||||
<a class="nav-link" href="/"><i class="bi bi-house-door me-1" aria-hidden="true"></i>ダッシュボード</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/assignments"><i class="bi bi-list-task me-1"></i>課題一覧</a>
|
||||
<a class="nav-link" href="/assignments"><i class="bi bi-list-task me-1" aria-hidden="true"></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>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/statistics"><i class="bi bi-bar-chart me-1"></i>統計</a>
|
||||
<a class="nav-link" href="/statistics"><i class="bi bi-bar-chart me-1" aria-hidden="true"></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>
|
||||
<a class="nav-link" href="/admin/users"><i class="bi bi-people me-1" aria-hidden="true"></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>
|
||||
<a class="nav-link" href="/admin/api-keys"><i class="bi bi-key me-1" aria-hidden="true"></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 class="nav-link dropdown-toggle" href="#" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-person-circle me-1" aria-hidden="true"></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><a class="dropdown-item" href="/profile"><i class="bi bi-person me-2" aria-hidden="true"></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>
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-box-arrow-right me-2" aria-hidden="true"></i>ログアウト
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -91,12 +87,24 @@
|
||||
<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
|
||||
<small class="text-muted" style="font-size: 0.75rem;">Licensed under <a
|
||||
href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a> | Time:
|
||||
{{.processing_time}}</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="modal fade" id="confirmModal" tabindex="-1" aria-labelledby="confirmModalLabel" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body" id="confirmModalBody"></div>
|
||||
<div class="modal-footer py-2 border-0">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">キャンセル</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" id="confirmModalOk">確認</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" .}}
|
||||
|
||||
@@ -2,94 +2,42 @@
|
||||
|
||||
{{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;
|
||||
}
|
||||
|
||||
.dashboard-stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.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>
|
||||
<div id="urgentBanner" class="urgent-banner py-3 text-center d-none" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="container position-relative">
|
||||
<i class="bi bi-exclamation-octagon-fill me-2" aria-hidden="true"></i>
|
||||
<span id="urgentMessage"></span>
|
||||
<div class="urgent-countdown mt-1">
|
||||
<i class="bi bi-stopwatch"></i> <span id="urgentCountdown"></span>
|
||||
<i class="bi bi-stopwatch" aria-hidden="true"></i> <span id="urgentCountdown"></span>
|
||||
</div>
|
||||
<button type="button" id="closeBanner" class="btn-close btn-close-white position-absolute top-0 end-0" aria-label="バナーを閉じる" onclick="document.getElementById('urgentBanner').classList.add('d-none')"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4"><i class="bi bi-house-door me-2"></i>ダッシュボード</h1>
|
||||
<h1 class="mb-4"><i class="bi bi-house-door me-2" aria-hidden="true"></i>ダッシュボード</h1>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
@@ -101,7 +49,7 @@
|
||||
<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>
|
||||
<i class="bi bi-list-task display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +64,7 @@
|
||||
<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>
|
||||
<i class="bi bi-calendar-event display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +79,7 @@
|
||||
<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>
|
||||
<i class="bi bi-calendar-week display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +94,7 @@
|
||||
<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>
|
||||
<i class="bi bi-exclamation-triangle display-4 opacity-50" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,20 +106,23 @@
|
||||
{{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>
|
||||
<div class="card-header bg-danger text-white"><i class="bi bi-exclamation-triangle me-2" aria-hidden="true"></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}}">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">高<span class="visually-hidden">(重要度:高)</span></span>{{end}}
|
||||
<strong>{{.Title}}</strong>
|
||||
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
|
||||
{{if .SoftDueDate}}<br><small class="text-info"><i class="bi bi-clock-history" aria-hidden="true"></i> 自分の期限: {{formatDateTime .SoftDueDate}}</small>{{end}}
|
||||
</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>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-success" aria-label="{{.Title}}を完了にする">
|
||||
<i class="bi bi-check-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -181,20 +132,23 @@
|
||||
{{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>
|
||||
<div class="card-header bg-warning text-dark"><i class="bi bi-calendar-event me-2" aria-hidden="true"></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}}">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">高<span class="visually-hidden">(重要度:高)</span></span>{{end}}
|
||||
<strong>{{.Title}}</strong>
|
||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||
{{if .SoftDueDate}}<br><small class="text-info"><i class="bi bi-clock-history" aria-hidden="true"></i> 自分の期限: {{formatDateTime .SoftDueDate}}</small>{{end}}
|
||||
</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>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-success" aria-label="{{.Title}}を完了にする">
|
||||
<i class="bi bi-check-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -204,20 +158,23 @@
|
||||
{{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>
|
||||
<div class="card-header bg-info text-white"><i class="bi bi-calendar-week me-2" aria-hidden="true"></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}}">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">高<span class="visually-hidden">(重要度:高)</span></span>{{end}}
|
||||
<strong>{{.Title}}</strong>
|
||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||
{{if .SoftDueDate}}<br><small class="text-info"><i class="bi bi-clock-history" aria-hidden="true"></i> 自分の期限: {{formatDateTime .SoftDueDate}}</small>{{end}}
|
||||
</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>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-success" aria-label="{{.Title}}を完了にする">
|
||||
<i class="bi bi-check-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -228,10 +185,10 @@
|
||||
|
||||
{{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>
|
||||
<i class="bi bi-emoji-smile display-1 text-success" aria-hidden="true"></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>
|
||||
<a href="/assignments/new" class="btn btn-primary"><i class="bi bi-plus-circle me-1" aria-hidden="true"></i>課題を登録</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -242,7 +199,8 @@
|
||||
var banner = document.getElementById('urgentBanner');
|
||||
var message = document.getElementById('urgentMessage');
|
||||
var countdown = document.getElementById('urgentCountdown');
|
||||
var body = document.body;
|
||||
|
||||
var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
var items = document.querySelectorAll('[data-priority="high"][data-due]');
|
||||
var mostUrgent = null;
|
||||
@@ -252,7 +210,6 @@
|
||||
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');
|
||||
@@ -260,57 +217,45 @@
|
||||
}
|
||||
});
|
||||
|
||||
var hasOverdueHigh = false;
|
||||
var overdueItems = document.querySelectorAll('[data-priority="high"]');
|
||||
overdueItems.forEach(function (item) {
|
||||
var hasOverdueHigh = Array.from(document.querySelectorAll('[data-priority="high"]')).some(function(item) {
|
||||
var due = parseInt(item.dataset.due);
|
||||
if (due && due < Date.now()) {
|
||||
hasOverdueHigh = true;
|
||||
}
|
||||
return due && due < Date.now();
|
||||
});
|
||||
|
||||
if (hasOverdueHigh) {
|
||||
banner.classList.remove('d-none');
|
||||
banner.classList.add('urgent-banner-danger');
|
||||
message.innerHTML = '🚨 <strong>期限切れの重要課題があります!</strong>';
|
||||
if (!reduced) banner.classList.add('anxiety-danger');
|
||||
message.textContent = '期限切れの重要課題があります!';
|
||||
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');
|
||||
if (!reduced) banner.classList.add('anxiety-danger');
|
||||
message.textContent = '「' + mostUrgent.title + '」の期限が迫っています!';
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
if (!reduced) 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');
|
||||
if (!reduced) banner.classList.add('anxiety-warning');
|
||||
message.textContent = '「' + mostUrgent.title + '」の期限が近づいています';
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
if (!reduced) setInterval(updateCountdown, 60000);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!mostUrgent) return;
|
||||
var now = Date.now();
|
||||
var diff = mostUrgent.due - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
countdown.textContent = '期限切れ!';
|
||||
return;
|
||||
}
|
||||
|
||||
var diff = mostUrgent.due - Date.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;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle display-1 text-danger"></i>
|
||||
<div class="text-center py-5" role="main">
|
||||
<i class="bi bi-exclamation-triangle display-1 text-danger" aria-hidden="true"></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>
|
||||
<p class="text-muted small">問題が続く場合はページを再読み込みするか、最初からやり直してください。</p>
|
||||
<div class="d-flex justify-content-center gap-2 mt-3">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>再読み込み
|
||||
</button>
|
||||
<a href="/" class="btn btn-primary">
|
||||
<i class="bi bi-house-door me-1" aria-hidden="true"></i>ダッシュボードに戻る
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -3,7 +3,7 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<h1 class="mb-4"><i class="bi bi-person me-2"></i>プロフィール</h1>
|
||||
<h1 class="mb-4"><i class="bi bi-person me-2" aria-hidden="true"></i>プロフィール</h1>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
@@ -11,25 +11,23 @@
|
||||
<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}}
|
||||
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
|
||||
{{if .success}}<div class="alert alert-success" role="status">{{.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>
|
||||
<p class="form-label mb-1 text-muted small">メールアドレス</p>
|
||||
<p class="mb-0 fw-bold">{{.user.Email}}</p>
|
||||
</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>
|
||||
<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>
|
||||
<p class="form-label mb-1 text-muted small">ロール</p>
|
||||
<p class="mb-0 fw-bold">{{if eq .user.Role "admin"}}管理者{{else}}ユーザー{{end}}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>更新</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,87 +38,98 @@
|
||||
<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}}
|
||||
{{if .passwordError}}<div class="alert alert-danger" role="alert">{{.passwordError}}</div>{{end}}
|
||||
{{if .passwordSuccess}}<div class="alert alert-success" role="status">{{.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>
|
||||
<input type="password" class="form-control" id="old_password" name="old_password" required autocomplete="current-password">
|
||||
</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>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8" autocomplete="new-password">
|
||||
<div class="form-text">8文字以上</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>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning"><i class="bi bi-key me-1"></i>パスワード変更</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-key me-1" aria-hidden="true"></i>パスワード変更</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知設定 -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-bell me-2"></i>通知設定</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-shield-lock me-2" aria-hidden="true"></i>2段階認証(2FA)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .notifyError}}<div class="alert alert-danger">{{.notifyError}}</div>{{end}}
|
||||
{{if .notifySuccess}}<div class="alert alert-success">{{.notifySuccess}}</div>{{end}}
|
||||
{{if .totpError}}<div class="alert alert-danger" role="alert">{{.totpError}}</div>{{end}}
|
||||
{{if .totpSuccess}}<div class="alert alert-success" role="status">{{.totpSuccess}}</div>{{end}}
|
||||
{{if .user.TOTPEnabled}}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span class="badge bg-success me-2"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>有効</span>
|
||||
<span class="text-muted">2段階認証が有効になっています</span>
|
||||
</div>
|
||||
<form method="POST" action="/profile/totp/disable">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="totp_disable_password" class="form-label">現在のパスワードを入力して無効化</label>
|
||||
<input type="password" class="form-control" id="totp_disable_password" name="password" placeholder="パスワード" required style="max-width:320px" autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-shield-x me-1" aria-hidden="true"></i>2段階認証を無効化
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span class="badge bg-secondary me-2"><i class="bi bi-x-lg me-1" aria-hidden="true"></i>無効</span>
|
||||
<span class="text-muted">2段階認証が設定されていません</span>
|
||||
</div>
|
||||
<p class="text-muted small">2段階認証を有効にするとセキュリティが向上します。Google Authenticator などのアプリが必要です。</p>
|
||||
<a href="/profile/totp/setup" class="btn btn-primary">
|
||||
<i class="bi bi-shield-plus me-1" aria-hidden="true"></i>2段階認証を設定する
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-bell me-2" aria-hidden="true"></i>通知設定</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .notifyError}}<div class="alert alert-danger" role="alert">{{.notifyError}}</div>{{end}}
|
||||
{{if .notifySuccess}}<div class="alert alert-success" role="status">{{.notifySuccess}}</div>{{end}}
|
||||
<form method="POST" action="/profile/notifications">
|
||||
{{.csrfField}}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-3"><i class="bi bi-telegram me-1"></i>Telegram</h6>
|
||||
<h6 class="mb-3"><i class="bi bi-telegram me-1" aria-hidden="true"></i>Telegram</h6>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="telegram_enabled"
|
||||
name="telegram_enabled" {{if .notifySettings.TelegramEnabled}}checked{{end}}>
|
||||
<input class="form-check-input" type="checkbox" id="telegram_enabled" name="telegram_enabled" {{if .notifySettings.TelegramEnabled}}checked{{end}}>
|
||||
<label class="form-check-label" for="telegram_enabled">Telegram通知を有効化</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="telegram_chat_id" class="form-label">Chat ID</label>
|
||||
<input type="text" class="form-control" id="telegram_chat_id" name="telegram_chat_id"
|
||||
value="{{.notifySettings.TelegramChatID}}" placeholder="例: 123456789">
|
||||
<input type="text" class="form-control" id="telegram_chat_id" name="telegram_chat_id" value="{{.notifySettings.TelegramChatID}}" placeholder="例: 123456789">
|
||||
<div class="form-text">
|
||||
Botに<code>/start</code>を送信後、<a href="https://t.me/userinfobot"
|
||||
target="_blank">@userinfobot</a>でIDを確認
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-3"><i class="bi bi-chat-dots me-1"></i>LINE Notify</h6>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="line_enabled" name="line_enabled"
|
||||
{{if .notifySettings.LineEnabled}}checked{{end}}>
|
||||
<label class="form-check-label" for="line_enabled">LINE通知を有効化</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="line_token" class="form-label">アクセストークン</label>
|
||||
<input type="password" class="form-control" id="line_token" name="line_token"
|
||||
value="{{.notifySettings.LineNotifyToken}}" placeholder="トークンを入力">
|
||||
<div class="form-text">
|
||||
<a href="https://notify-bot.line.me/my/" target="_blank">LINE Notify</a>でトークンを発行
|
||||
Botに<code>/start</code>を送信後、<a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer">@userinfobot</a>でIDを確認
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-3">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="notify_on_create" name="notify_on_create"
|
||||
{{if .notifySettings.NotifyOnCreate}}checked{{end}}>
|
||||
<input class="form-check-input" type="checkbox" id="notify_on_create" name="notify_on_create" {{if .notifySettings.NotifyOnCreate}}checked{{end}}>
|
||||
<label class="form-check-label" for="notify_on_create">
|
||||
<i class="bi bi-plus-circle me-1"></i>課題追加時に通知する
|
||||
<i class="bi bi-plus-circle me-1" aria-hidden="true"></i>課題追加時に通知する
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>通知設定を保存</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
88
web/templates/pages/totp_setup.html
Normal file
88
web/templates/pages/totp_setup.html
Normal file
@@ -0,0 +1,88 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7 col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-shield-lock me-2" aria-hidden="true"></i>2段階認証の設定</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{{if .error}}
|
||||
<div class="alert alert-danger" role="alert">{{.error}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="alert alert-info" role="note">
|
||||
<i class="bi bi-info-circle me-1" aria-hidden="true"></i>
|
||||
Google Authenticator、Authy などの認証アプリを使用してください。
|
||||
</div>
|
||||
|
||||
<h6 class="fw-bold mb-3">手順</h6>
|
||||
<ol class="mb-4">
|
||||
<li class="mb-2">認証アプリを開き、新しいアカウントを追加してください。</li>
|
||||
<li class="mb-2">下のQRコードをスキャンするか、シークレットキーを手動で入力してください。</li>
|
||||
<li class="mb-2">アプリに表示された6桁のコードを下の欄に入力して確認してください。</li>
|
||||
</ol>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<img src="data:image/png;base64,{{.qrCode}}" alt="2段階認証設定用QRコード" class="border rounded" style="max-width:200px">
|
||||
<p class="text-muted small mt-2">QRコードを再スキャンしたい場合はページを再読み込みしてください。</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold" for="secretKey">シークレットキー(手動入力の場合)</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="secretKey" value="{{.secret}}" readonly aria-label="シークレットキー">
|
||||
<button class="btn btn-outline-secondary" type="button" id="copySecretBtn" aria-label="シークレットキーをコピー">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">認証アプリで「手動入力」を選択し、このキーを入力してください。</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profile/totp/setup">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="totp_password" class="form-label fw-bold">現在のパスワード</label>
|
||||
<input type="password" class="form-control" id="totp_password" name="password" placeholder="パスワードを入力" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="totp_code" class="form-label fw-bold">認証コードで確認</label>
|
||||
<input type="text" class="form-control form-control-lg text-center" id="totp_code" name="totp_code" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="off" autofocus required>
|
||||
<div class="form-text">認証アプリに表示された6桁のコードを入力してください。</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-shield-check me-1" aria-hidden="true"></i>有効化
|
||||
</button>
|
||||
<a href="/profile" class="btn btn-outline-secondary">キャンセル</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
document.getElementById('copySecretBtn').addEventListener('click', function() {
|
||||
var btn = this;
|
||||
var val = document.getElementById('secretKey').value;
|
||||
if (!navigator.clipboard) {
|
||||
showCopyFeedback('クリップボードがサポートされていません。手動でコピーしてください。');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(val).then(function() {
|
||||
btn.innerHTML = '<i class="bi bi-check-lg" aria-hidden="true"></i>';
|
||||
btn.setAttribute('aria-label', 'コピーしました');
|
||||
setTimeout(function() {
|
||||
btn.innerHTML = '<i class="bi bi-clipboard" aria-hidden="true"></i>';
|
||||
btn.setAttribute('aria-label', 'シークレットキーをコピー');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
showCopyFeedback('コピーに失敗しました: ' + (err.message || err));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -5,14 +5,14 @@
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>繰り返し課題の編集</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2" aria-hidden="true"></i>繰り返し課題の編集</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/recurring/{{.recurring.ID}}">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger" aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
|
||||
<input type="text" class="form-control" id="title" name="title" value="{{.recurring.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -22,9 +22,9 @@
|
||||
<div class="mb-3">
|
||||
<label for="priority" class="form-label">重要度</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low" {{if eq .recurring.Priority "low"}}selected{{end}}>小</option>
|
||||
<option value="low" {{if eq .recurring.Priority "low" }}selected{{end}}>低</option>
|
||||
<option value="medium" {{if eq .recurring.Priority "medium"}}selected{{end}}>中</option>
|
||||
<option value="high" {{if eq .recurring.Priority "high"}}selected{{end}}>大</option>
|
||||
<option value="high" {{if eq .recurring.Priority "high" }}selected{{end}}>高</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -38,27 +38,27 @@
|
||||
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body py-3">
|
||||
<h6 class="mb-3"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
|
||||
<h6 class="mb-3"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>繰り返し設定</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_type" name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||
<option value="daily" {{if eq .recurring.RecurrenceType "daily"}}selected{{end}}>毎日</option>
|
||||
<option value="weekly" {{if eq .recurring.RecurrenceType "weekly"}}selected{{end}}>毎週</option>
|
||||
<option value="daily" {{if eq .recurring.RecurrenceType "daily" }}selected{{end}}>毎日</option>
|
||||
<option value="weekly" {{if eq .recurring.RecurrenceType "weekly" }}selected{{end}}>毎週</option>
|
||||
<option value="monthly" {{if eq .recurring.RecurrenceType "monthly"}}selected{{end}}>毎月</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label for="recurrence_interval" class="form-label small">間隔</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="{{.recurring.RecurrenceInterval}}" min="1" max="12">
|
||||
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="{{.recurring.RecurrenceInterval}}" min="1" max="12" onchange="updateLeadDaysMax()">
|
||||
<span class="input-group-text" id="interval_label">{{if eq .recurring.RecurrenceType "daily"}}日{{else if eq .recurring.RecurrenceType "weekly"}}週{{else}}月{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="weekday_group" class="mb-3">
|
||||
<label class="form-label small">曜日</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<div class="btn-group btn-group-sm w-100" role="group" aria-label="曜日選択">
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" value="0" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 0}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" value="1" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 1}}checked{{end}}{{end}}>
|
||||
@@ -84,6 +84,17 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">リストに追加するタイミング</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="input-group input-group-sm" style="width: 120px;">
|
||||
<input type="number" class="form-control" id="generation_lead_days" name="generation_lead_days" value="{{.recurring.GenerationLeadDays}}" min="0" max="365">
|
||||
<span class="input-group-text">日前</span>
|
||||
</div>
|
||||
<input type="time" class="form-control form-control-sm" id="generation_lead_time" name="generation_lead_time" value="{{.recurring.GenerationLeadTime}}" style="width: 110px;">
|
||||
</div>
|
||||
<div class="form-text small text-muted" id="lead_days_hint"></div>
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="small text-muted">状態:</span>
|
||||
@@ -97,19 +108,19 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>更新</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
{{if .recurring.IsActive}}
|
||||
<button type="button" class="btn btn-outline-danger" onclick="if(confirm('繰り返しを停止しますか?今後新しい課題は自動作成されなくなります。')) document.getElementById('stopForm').submit();">
|
||||
<i class="bi bi-stop-fill me-1"></i>停止
|
||||
<button type="button" class="btn btn-outline-danger" id="stopBtn">
|
||||
<i class="bi bi-stop-fill me-1" aria-hidden="true"></i>停止
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" class="btn btn-outline-success" onclick="document.getElementById('resumeForm').submit();">
|
||||
<i class="bi bi-play-fill me-1"></i>再開
|
||||
<i class="bi bi-play-fill me-1" aria-hidden="true"></i>再開
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" onclick="if(confirm('この繰り返し設定を削除しますか?この操作は取り消せません。')) document.getElementById('deleteForm').submit();">
|
||||
<i class="bi bi-trash me-1"></i>削除
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" id="deleteBtn">
|
||||
<i class="bi bi-trash me-1" aria-hidden="true"></i>削除
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -132,17 +143,58 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getLeadDaysMax() {
|
||||
var type = document.getElementById('recurrence_type').value;
|
||||
var interval = parseInt(document.getElementById('recurrence_interval').value) || 1;
|
||||
if (type === 'daily') return interval;
|
||||
if (type === 'weekly') return interval * 7;
|
||||
if (type === 'monthly') return interval * 28;
|
||||
return 0;
|
||||
}
|
||||
function updateLeadDaysMax() {
|
||||
var max = getLeadDaysMax();
|
||||
var input = document.getElementById('generation_lead_days');
|
||||
input.max = max;
|
||||
if (parseInt(input.value) > max) input.value = max;
|
||||
var type = document.getElementById('recurrence_type').value;
|
||||
var labels = { daily: '日', weekly: '週', monthly: 'ヶ月' };
|
||||
var interval = document.getElementById('recurrence_interval').value;
|
||||
document.getElementById('lead_days_hint').textContent =
|
||||
max === 0 ? '間隔が1日のため、事前追加は設定できません' :
|
||||
'最大' + max + '日前まで指定可能(繰り返し間隔' + interval + labels[type] + '以内)';
|
||||
}
|
||||
function updateRecurrenceOptions() {
|
||||
var type = document.getElementById('recurrence_type').value;
|
||||
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
|
||||
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
|
||||
var label = document.getElementById('interval_label');
|
||||
if (label) {
|
||||
if (type === 'daily') label.textContent = '日';
|
||||
else if (type === 'weekly') label.textContent = '週';
|
||||
else if (type === 'monthly') label.textContent = '月';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateLeadDaysMax();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
updateRecurrenceOptions();
|
||||
|
||||
var stopBtn = document.getElementById('stopBtn');
|
||||
if (stopBtn) {
|
||||
stopBtn.addEventListener('click', function() {
|
||||
showConfirmModal('繰り返しを停止しますか?今後新しい課題は自動作成されなくなります。', function() {
|
||||
document.getElementById('stopForm').submit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var deleteBtn = document.getElementById('deleteBtn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
showConfirmModal('この繰り返し設定を削除しますか?この操作は取り消せません。', function() {
|
||||
document.getElementById('deleteForm').submit();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -2,29 +2,27 @@
|
||||
|
||||
{{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-arrow-repeat me-2"></i>繰り返し設定一覧</h4>
|
||||
</div>
|
||||
<h4 class="mb-0 fw-bold"><i class="bi bi-arrow-repeat me-2" aria-hidden="true"></i>繰り返し設定一覧</h4>
|
||||
<a href="/assignments" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
|
||||
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>課題一覧に戻る
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<table class="table table-hover mb-0 custom-table" aria-label="繰り返し設定一覧">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">タイトル</th>
|
||||
<th>科目</th>
|
||||
<th>繰り返し</th>
|
||||
<th>状態</th>
|
||||
<th class="text-end pe-3">操作</th>
|
||||
<th scope="col" class="ps-3">タイトル</th>
|
||||
<th scope="col">科目</th>
|
||||
<th scope="col">繰り返し</th>
|
||||
<th scope="col">状態</th>
|
||||
<th scope="col" class="text-end pe-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .recurrings}}
|
||||
<tr>
|
||||
<tr class="recurring-row">
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold">{{.Title}}</div>
|
||||
{{if .Description}}
|
||||
@@ -38,9 +36,7 @@
|
||||
<span class="text-muted">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-dark">{{recurringSummary .}}</span>
|
||||
</td>
|
||||
<td><span class="text-dark">{{recurringSummary .}}</span></td>
|
||||
<td>
|
||||
{{if .IsActive}}
|
||||
<span class="badge bg-success">有効</span>
|
||||
@@ -49,15 +45,19 @@
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="/recurring/{{.ID}}/edit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
<a href="/recurring/{{.ID}}/edit" class="btn btn-sm btn-outline-primary" aria-label="{{.Title}}を編集">
|
||||
<i class="bi bi-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-4 text-muted">
|
||||
繰り返し設定がありません
|
||||
<td colspan="5" class="text-center py-5">
|
||||
<i class="bi bi-arrow-repeat display-4 text-muted" aria-hidden="true"></i>
|
||||
<p class="mt-2 mb-1 text-muted fw-bold">繰り返し設定がありません</p>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary mt-1">
|
||||
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>繰り返し課題を作成する
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user