10 Commits

27 changed files with 2477 additions and 1245 deletions

View File

@@ -3,35 +3,50 @@ 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
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)

View File

@@ -71,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 |
@@ -232,7 +233,18 @@ REST API認証用のAPIキーを管理するモデル。
|------------|----------|
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
### 4.5 プロフィール機能
### 4.5 CSVエクスポート機能
| 機能 | 説明 |
|------|------|
| CSVダウンロード | 統計ページから課題一覧をCSVファイルとしてダウンロード (`GET /assignments/export`) |
| 期間フィルタ | 提出期限(`due_date`)を基準に開始日・終了日で絞り込み可能 |
| 科目フィルタ | 特定の科目のみを対象にエクスポート可能 |
| 全件エクスポート | 期間・科目を未指定の場合は全課題を出力 |
| CSVカラム | ID, タイトル, 科目, 説明, 重要度, 提出期限, 完了状態, 完了日時, 登録日時 |
| エンコーディング | UTF-8 BOM付きExcel直接開き対応 |
### 4.6 プロフィール機能
| 機能 | 説明 |
|------|------|

View File

@@ -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 {

View File

@@ -239,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"`
@@ -352,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
@@ -367,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"`
@@ -448,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

View File

@@ -1,7 +1,9 @@
package handler
import (
"encoding/csv"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -33,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)
@@ -63,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 {
@@ -70,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
@@ -84,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)
@@ -93,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,
@@ -104,17 +127,75 @@ 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": "課題登録",
"isAdmin": role == "admin",
"userName": name,
"currentWeekday": int(now.Weekday()),
"currentDay": now.Day(),
"title": "課題登録",
"isAdmin": role == "admin",
"userName": name,
"currentWeekday": int(now.Weekday()),
"currentDay": now.Day(),
"defaultDueDate": defaultDue.Format("2006-01-02T15:04"),
"defaultSoftDueDate": defaultSoftDue.Format("2006-01-02T15:04"),
})
}
@@ -174,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" {
@@ -217,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,
@@ -233,6 +330,8 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
EndDate: endDate,
ReminderEnabled: reminderEnabled,
UrgentReminderEnabled: urgentReminderEnabled,
GenerationLeadDays: generationLeadDays,
GenerationLeadTime: generationLeadTime,
FirstDueDate: dueDate,
}
@@ -253,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)
@@ -340,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
@@ -353,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 == "" {
@@ -459,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)
@@ -580,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,
@@ -594,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)

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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
}
if filter == "completed" {
dbQuery = dbQuery.Order("completed_at DESC")
} else {
dbQuery = dbQuery.Order("due_date ASC")
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" {
orderClause = "is_pinned DESC, completed_at DESC"
} else {
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
}

View File

@@ -25,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)
@@ -243,11 +259,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)

View File

@@ -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,
}
}

View File

@@ -170,10 +170,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 {
@@ -191,12 +192,13 @@ 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"),
)
var errors []string
@@ -268,9 +270,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
}

View File

@@ -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 {

View File

@@ -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 {
border-bottom: 1px solid #dee2e6;
vertical-align: middle;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
font-size: 0.9rem;
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;
}
.custom-table tbody tr:last-child td {
border-bottom: none;
.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;
}
/* Compact form elements */
.form-control-custom,
.form-select-custom,
.btn-custom {
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
border-radius: 0.25rem;
@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;
}
.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;
}
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;
}
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;
}
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;
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;
}
}
/* Custom Tab Styles */
.nav-tabs .nav-link {
font-size: 0.9rem;
padding: 0.5rem 1rem;
color: #6c757d;
/* text-muted */
border: none;
@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;
}
}
.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;
}

View File

@@ -7,9 +7,7 @@ const XSS = {
},
setTextSafe: function (element, text) {
if (element) {
element.textContent = text;
}
if (element) element.textContent = text;
},
sanitizeUrl: function (url) {
@@ -17,13 +15,9 @@ const XSS = {
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;
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href;
} catch (e) {
if (cleaned.startsWith('/') && !cleaned.startsWith('//')) {
return cleaned;
}
if (cleaned.startsWith('/') && !cleaned.startsWith('//')) return cleaned;
}
return '';
}
@@ -31,26 +25,109 @@ const XSS = {
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 () {
alert.classList.add('fade');
setTimeout(function () {
alert.remove();
}, 150);
setTimeout(function () { alert.remove(); }, 150);
}, 5000);
});
const confirmForms = document.querySelectorAll('form[data-confirm]');
confirmForms.forEach(function (form) {
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
form.addEventListener('submit', function (e) {
if (!confirm(form.dataset.confirm)) {
e.preventDefault();
}
e.preventDefault();
const msg = form.dataset.confirm;
showConfirmModal(msg, function () { form.submit(); });
});
});
document.querySelectorAll('form:not([data-confirm])').forEach(setupFormSubmitOnce);
applySubjectColors();
const dueDateInput = document.getElementById('due_date');
if (dueDateInput && !dueDateInput.value) {
const tomorrow = new Date();
@@ -58,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();
}

View File

@@ -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,11 +113,20 @@
{{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>
{{end}}
{{end}}

View File

@@ -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}}>
{{if eq .Role "admin"}}
<form action="/admin/users/{{.ID}}/role" method="POST" class="d-inline"
data-confirm="このユーザーを一般ユーザーに降格しますか?">
<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}}
<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>
<form action="/admin/users/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('このユーザーを削除しますか?')">
{{else}}
<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="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>
{{else}}<span class="text-muted">-</span>{{end}}
{{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,8 +71,8 @@
</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}}
{{end}}
{{end}}

View File

@@ -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>
@@ -123,4 +114,4 @@
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
}
</script>
{{end}}
{{end}}

View File

@@ -1,398 +1,361 @@
{{template "base" .}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<h4 class="mb-0 fw-bold"><i class="bi bi-list-task me-2"></i>課題一覧</h4>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-secondary text-white" onclick="toggleCountdown()" id="toggleCountdownBtn">
<i class="bi bi-clock me-1"></i><span id="countdownBtnText">カウントダウン表示中</span>
<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>
<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>
<option value="high" {{if eq .priority "high" }}selected{{end}}></option>
<option value="medium" {{if eq .priority "medium" }}selected{{end}}></option>
<option value="low" {{if eq .priority "low" }}selected{{end}}></option>
<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="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 class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 custom-table">
<thead class="bg-secondary-subtle">
<tr>
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
<th 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>
</tr>
</thead>
<tbody>
{{range .assignments}}
<tr class="assignment-row border-bottom" data-due-ts="{{.DueDate.Unix}}"
data-completed="{{.IsCompleted}}">
<td class="ps-3 text-center">
{{if .IsCompleted}}
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit"
class="btn btn-link p-0 text-success text-decoration-none hover-dark"
title="未完了に戻す">
<i class="bi bi-check-circle-fill"></i>
</button>
</form>
{{else}}
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
<button type="submit"
class="btn btn-link p-0 text-secondary text-decoration-none hover-dark"
title="完了にする">
<i class="bi bi-circle"></i>
</button>
</form>
{{end}}
</td>
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
<td>
{{if eq .Priority "high"}}
<span class="badge bg-danger text-white border-0 fw-bold small"></span>
{{else if eq .Priority "medium"}}
<span class="badge bg-warning text-dark border-0 fw-bold small"></span>
{{else}}
<span class="badge bg-dark text-white border-0 fw-bold small"></span>
{{end}}
</td>
<td>
<div class="d-flex align-items-center">
<div class="fw-bold text-dark text-truncate" style="max-width: 280px;">{{.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}}"
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>
</button>
{{end}}
</div>
</td>
<td>
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
</div>
</td>
<td class="countdown-col">
{{if not .IsCompleted}}
<span class="countdown small fw-bold font-monospace text-dark">...</span>
{{else}}
<span class="text-secondary small fw-bold">-</span>
{{end}}
</td>
<td class="text-end pe-3">
<div class="btn-group">
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
<i class="bi bi-pencil-fill"></i>
<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" aria-label="課題一覧">
<thead class="bg-secondary-subtle">
<tr>
<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>
{{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>
</button>
{{else}}
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('削除しますか?');">
</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 {{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" data-row-id="{{.ID}}">
<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-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" 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 btn-touch" aria-label="完了にする">
<i class="bi bi-circle" aria-hidden="true"></i>
</button>
</form>
{{end}}
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="text-center py-4 text-secondary fw-bold small">
課題なし
</td>
</tr>
{{end}}
</tbody>
</table>
</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 class="visually-hidden">(重要度:高)</span></span>
{{else if eq .Priority "medium"}}
<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 class="visually-hidden">(重要度:低)</span></span>
{{end}}
</td>
<td>
<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 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}}"
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>
</td>
<td class="countdown-col">
{{if not .IsCompleted}}
<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="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 .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" 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 btn-touch" aria-label="削除">
<i class="bi bi-trash-fill" aria-hidden="true"></i>
</button>
</form>
{{end}}
</div>
</td>
</tr>
{{else}}
<tr>
<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}}
</tbody>
</table>
</div>
</div>
{{if gt .totalPages 1}}
<div class="card-footer bg-white border-top-0 py-2">
<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}}&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" 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}}&subject={{.subject}}&sort={{.sort}}" aria-label="次のページ">
<i class="bi bi-chevron-right" aria-hidden="true"></i>
</a>
</li>
</ul>
</nav>
</div>
{{end}}
</div>
{{if gt .totalPages 1}}
<div class="card-footer bg-white border-top-0 py-2">
<nav>
<ul class="pagination pagination-sm justify-content-center mb-0">
<li class="page-item {{if not .hasPrev}}disabled{{end}}">
<a class="page-link border-0 text-secondary"
href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
<li class="page-item disabled">
<span class="page-link border-0 text-dark fw-bold">{{.currentPage}} / {{.totalPages}}</span>
</li>
<li class="page-item {{if not .hasNext}}disabled{{end}}">
<a class="page-link border-0 text-secondary"
href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
{{end}}
</div>
<script>
function updateCountdowns() {
const now = new Date();
document.querySelectorAll('.assignment-row').forEach(row => {
if (row.getAttribute('data-completed') === 'true') return;
<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>
{{end}}
<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}}

View File

@@ -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;">
<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" aria-hidden="true"></i>登録</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div>
</div>
</div>
</div>
<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 (type === 'daily') label.textContent = '日';
else if (type === 'weekly') label.textContent = '';
else if (type === 'monthly') label.textContent = '';
if (label) {
if (type === 'daily') label.textContent = '';
else if (type === 'weekly') label.textContent = '';
else if (type === 'monthly') label.textContent = '月';
}
updateLeadDaysMax();
}
document.querySelectorAll('input[name="end_type"]').forEach(radio => {
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}}
{{end}}

View File

@@ -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>
@@ -381,28 +326,23 @@
</script>
<script>
(function () {
var data = JSON.parse(document.getElementById('subjectsData').textContent);
var csrfToken = data.csrfToken;
var subjects = data.subjects;
var PAGE_SIZE = 10;
var activeSubjects = subjects.filter(function (s) { return !s.isArchived; });
var data = JSON.parse(document.getElementById('subjectsData').textContent);
var csrfToken = data.csrfToken;
var subjects = data.subjects;
var PAGE_SIZE = 10;
var activeSubjects = subjects.filter(function (s) { return !s.isArchived; });
var archivedSubjects = subjects.filter(function (s) { return s.isArchived; });
var activePage = 1;
var archivedPage = 1;
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');
}, 2000);
}).catch(function (err) {
console.error('Failed to copy: ', err);
alert('画像のコピーに失敗しました。\nエラー: ' + err.message + '\n「画像を保存」を使用してください。');
});
} catch (err) {
console.error('Failed to create blob: ', err);
alert('画像データの生成に失敗しました: ' + err.message);
}
};
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) {
showCopyFeedback('画像のコピーに失敗しました。「画像を保存」をご利用ください。(' + (err.message || err) + '');
});
});
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">' +
var pP = (pending / total * 100).toFixed(1);
var oP = (overdue / total * 100).toFixed(1);
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>';
'<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="#">&laquo;</a>';
prev.innerHTML = '<a class="page-link" href="#" aria-label="前のページ">&laquo;</a>';
if (page > 1) prev.onclick = function (e) { e.preventDefault(); cb(page - 1); };
el.appendChild(prev);
for (var i = 1; i <= total; i++) {
@@ -522,28 +446,28 @@
}
var next = document.createElement('li');
next.className = 'page-item' + (page === total ? ' disabled' : '');
next.innerHTML = '<a class="page-link" href="#">&raquo;</a>';
next.innerHTML = '<a class="page-link" href="#" aria-label="次のページ">&raquo;</a>';
if (page < total) next.onclick = function (e) { e.preventDefault(); cb(page + 1); };
el.appendChild(next);
}
function renderActiveSubjects() {
var start = (activePage - 1) * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, activeSubjects.length);
var start = (activePage - 1) * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, activeSubjects.length);
var totalPages = Math.ceil(activeSubjects.length / PAGE_SIZE);
document.getElementById('activeSubjectsBody').innerHTML = activeSubjects.slice(start, end).map(function (s) { return renderRow(s, false); }).join('');
document.getElementById('activeCount').textContent = activeSubjects.length;
document.getElementById('activeCount').textContent = activeSubjects.length;
document.getElementById('activePageInfo').textContent = activeSubjects.length > 0 ? (start + 1) + '-' + end + ' / ' + activeSubjects.length + ' 件' : '0 件';
renderPagination('activePagination', activePage, totalPages, function (p) { activePage = p; renderActiveSubjects(); });
document.getElementById('activeSubjectsCard').style.display = activeSubjects.length > 0 ? 'block' : 'none';
}
function renderArchivedSubjects() {
var start = (archivedPage - 1) * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, archivedSubjects.length);
var start = (archivedPage - 1) * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, archivedSubjects.length);
var totalPages = Math.ceil(archivedSubjects.length / PAGE_SIZE);
document.getElementById('archivedSubjectsBody').innerHTML = archivedSubjects.slice(start, end).map(function (s) { return renderRow(s, true); }).join('');
document.getElementById('archivedCount').textContent = archivedSubjects.length;
document.getElementById('archivedCount').textContent = archivedSubjects.length;
document.getElementById('archivedPageInfo').textContent = archivedSubjects.length > 0 ? (start + 1) + '-' + end + ' / ' + archivedSubjects.length + ' 件' : '0 件';
renderPagination('archivedPagination', archivedPage, totalPages, function (p) { archivedPage = p; renderArchivedSubjects(); });
document.getElementById('archivedSubjectsCard').style.display = archivedSubjects.length > 0 ? 'block' : 'none';
@@ -553,9 +477,9 @@
renderArchivedSubjects();
if (activeSubjects.length === 0 && archivedSubjects.length === 0) {
document.getElementById('noSubjectsCard').classList.remove('d-none');
document.getElementById('activeSubjectsCard').style.display = 'none';
document.getElementById('activeSubjectsCard').style.display = 'none';
document.getElementById('archivedSubjectsCard').style.display = 'none';
}
})();
</script>
{{end}}
{{end}}

View File

@@ -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" .}}
@@ -106,4 +114,4 @@
{{end}}
{{define "head"}}{{end}}
{{define "scripts"}}{{end}}
{{define "scripts"}}{{end}}

View File

@@ -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}}
@@ -239,20 +196,20 @@
{{define "scripts"}}
<script>
(function () {
var banner = document.getElementById('urgentBanner');
var message = document.getElementById('urgentMessage');
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;
var mostUrgentDue = Infinity;
items.forEach(function (item) {
var due = parseInt(item.dataset.due);
var now = Date.now();
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,59 +217,47 @@
}
});
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 days = Math.floor(diff / 86400000);
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 = 'あと ';
var mins = Math.floor((diff % 3600000) / 60000);
var secs = Math.floor((diff % 60000) / 1000);
var text = 'あと ';
if (days > 0) text += days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
else if (hours > 0) text += hours + '時間 ' + mins + '分 ' + secs + '秒';
else text += mins + '分 ' + secs + '秒';
countdown.textContent = text;
}
})();
</script>
{{end}}
{{end}}

View File

@@ -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}}
{{end}}

View File

@@ -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,111 +38,101 @@
<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>
<!-- 2段階認証設定 -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証2FA</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 .totpError}}<div class="alert alert-danger">{{.totpError}}</div>{{end}}
{{if .totpSuccess}}<div class="alert alert-success">{{.totpSuccess}}</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"></i>有効</span>
<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">
<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"></i>2段階認証を無効化
<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"></i>無効</span>
<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"></i>2段階認証を設定する
<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"></i>通知設定</h5>
<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">{{.notifyError}}</div>{{end}}
{{if .notifySuccess}}<div class="alert alert-success">{{.notifySuccess}}</div>{{end}}
{{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を確認
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>
</div>
</div>
{{end}}
{{end}}

View File

@@ -5,15 +5,15 @@
<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"></i>2段階認証の設定</h5>
<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">{{.error}}</div>
<div class="alert alert-danger" role="alert">{{.error}}</div>
{{end}}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
<div class="alert alert-info" role="note">
<i class="bi bi-info-circle me-1" aria-hidden="true"></i>
Google Authenticator、Authy などの認証アプリを使用してください。
</div>
@@ -25,17 +25,16 @@
</ol>
<div class="text-center mb-4">
<img src="data:image/png;base64,{{.qrCode}}" alt="QRコード" class="border rounded"
style="max-width:200px">
<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">シークレットキー(手動入力の場合)</label>
<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>
<button class="btn btn-outline-secondary" type="button" onclick="copySecret()" title="コピー">
<i class="bi bi-clipboard"></i>
<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>
@@ -45,19 +44,16 @@
{{.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>
<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>
<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"></i>有効化
<i class="bi bi-shield-check me-1" aria-hidden="true"></i>有効化
</button>
<a href="/profile" class="btn btn-outline-secondary">キャンセル</a>
</div>
@@ -70,14 +66,23 @@
{{define "scripts"}}
<script>
function copySecret() {
const el = document.getElementById('secretKey');
el.select();
navigator.clipboard.writeText(el.value).then(() => {
const btn = el.nextElementSibling;
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
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}}
{{end}}

View File

@@ -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">
@@ -35,30 +35,30 @@
<label for="due_time" class="form-label">時刻</label>
<input type="time" class="form-control" id="due_time" name="due_time" value="{{.recurring.DueTime}}">
</div>
<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}}>
@@ -83,7 +83,18 @@
{{end}}
</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>
@@ -95,25 +106,25 @@
</div>
</div>
</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>
{{if .recurring.IsActive}}
<form id="stopForm" action="/recurring/{{.recurring.ID}}/stop" method="POST" class="d-none">
{{.csrfField}}
@@ -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';
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 (type === 'daily') label.textContent = '日';
else if (type === 'weekly') label.textContent = '';
else if (type === 'monthly') label.textContent = '';
if (label) {
if (type === 'daily') label.textContent = '';
else if (type === 'weekly') label.textContent = '';
else if (type === 'monthly') label.textContent = '月';
}
updateLeadDaysMax();
}
document.addEventListener('DOMContentLoaded', function() {
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}}
{{end}}

View File

@@ -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}}
@@ -65,4 +65,4 @@
</table>
</div>
</div>
{{end}}
{{end}}