Initial implementation
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Build output
|
||||
bin
|
||||
/bin
|
||||
*.o
|
||||
*.a
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Configuration files (keep example)
|
||||
config.ini
|
||||
!config.example.ini
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# OS generated files
|
||||
Thumbs.db
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
18
README.md
18
README.md
@@ -35,4 +35,20 @@ From either the calendar grid or the timeline view, the user can click on a spec
|
||||
- The application is compiled into a single binary (plus a config file) for easy deployment.
|
||||
- Configuration is done via a config file that is read at startup.
|
||||
- The application can be run as a standalone server on a specified port.
|
||||
- Static files (CSS, JS, images) are inculeded in the binary using Go's embed package.
|
||||
- Static files (CSS, JS, images) are inculeded in the binary using Go's embed package.
|
||||
|
||||
## Building
|
||||
|
||||
To compile the application and create a runnable binary in the `bin` folder:
|
||||
|
||||
```bash
|
||||
go build -o bin/chronological ./cmd/chronological
|
||||
```
|
||||
|
||||
This will create the `chronological` executable in the `bin` directory. You can then run it with:
|
||||
|
||||
```bash
|
||||
./bin/chronological -config config.ini
|
||||
```
|
||||
|
||||
If no config file is specified, the application will look for `config.ini` in the current directory and use default settings if not found.
|
||||
49
cmd/chronological/main.go
Normal file
49
cmd/chronological/main.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Package main is the entry point for the Chronological application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/luxick/chronological/internal/config"
|
||||
"github.com/luxick/chronological/internal/server"
|
||||
"github.com/luxick/chronological/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.ini", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Could not load config file: %v", err)
|
||||
log.Println("Using default configuration")
|
||||
cfg = &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Port: 8080,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Printf("Warning: Configuration validation: %v", err)
|
||||
}
|
||||
|
||||
// Set embedded filesystems from web package
|
||||
server.TemplatesFS = web.TemplatesFS
|
||||
server.StaticFS = web.StaticFS
|
||||
|
||||
// Create and run server
|
||||
srv, err := server.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
|
||||
if err := srv.Run(); err != nil {
|
||||
log.Printf("Server error: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
22
config.example.ini
Normal file
22
config.example.ini
Normal file
@@ -0,0 +1,22 @@
|
||||
[server]
|
||||
port = 8080
|
||||
|
||||
[calendar]
|
||||
# Path to local .ics file for calendar events
|
||||
local_ics = /path/to/calendar.ics
|
||||
|
||||
[diary]
|
||||
# Path to .ics file for diary/journal entries
|
||||
ics_file = /path/to/diary.ics
|
||||
|
||||
[caldav]
|
||||
# Enable CalDAV sync for remote calendars
|
||||
enabled = false
|
||||
url = https://caldav.example.com/calendars/user/
|
||||
username = user
|
||||
password = password
|
||||
|
||||
[photos]
|
||||
# Root folder containing year subfolders with photos
|
||||
# Photos should be named: YYYY-MM-DD_description.ext
|
||||
root_folder = /path/to/photos
|
||||
14
go.mod
Normal file
14
go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module github.com/luxick/chronological
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
)
|
||||
18
go.sum
Normal file
18
go.sum
Normal file
@@ -0,0 +1,18 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
144
internal/config/config.go
Normal file
144
internal/config/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Package config handles loading and validation of application configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Calendar CalendarConfig
|
||||
Diary DiaryConfig
|
||||
CalDAV CalDAVConfig
|
||||
Photos PhotosConfig
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
// CalendarConfig holds calendar ICS file settings.
|
||||
type CalendarConfig struct {
|
||||
LocalICS string
|
||||
}
|
||||
|
||||
// DiaryConfig holds diary ICS file settings.
|
||||
type DiaryConfig struct {
|
||||
ICSFile string
|
||||
}
|
||||
|
||||
// CalDAVConfig holds CalDAV remote calendar settings.
|
||||
type CalDAVConfig struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// PhotosConfig holds photo storage settings.
|
||||
type PhotosConfig struct {
|
||||
RootFolder string
|
||||
}
|
||||
|
||||
// Load reads configuration from the specified INI file.
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg, err := ini.Load(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: cfg.Section("server").Key("port").MustInt(8080),
|
||||
},
|
||||
Calendar: CalendarConfig{
|
||||
LocalICS: cfg.Section("calendar").Key("local_ics").String(),
|
||||
},
|
||||
Diary: DiaryConfig{
|
||||
ICSFile: cfg.Section("diary").Key("ics_file").String(),
|
||||
},
|
||||
CalDAV: CalDAVConfig{
|
||||
Enabled: cfg.Section("caldav").Key("enabled").MustBool(false),
|
||||
URL: cfg.Section("caldav").Key("url").String(),
|
||||
Username: cfg.Section("caldav").Key("username").String(),
|
||||
Password: cfg.Section("caldav").Key("password").String(),
|
||||
},
|
||||
Photos: PhotosConfig{
|
||||
RootFolder: cfg.Section("photos").Key("root_folder").String(),
|
||||
},
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Validate checks that all required configuration values are set and valid.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
||||
return fmt.Errorf("invalid server port: %d", c.Server.Port)
|
||||
}
|
||||
|
||||
// Validate calendar ICS path if specified
|
||||
if c.Calendar.LocalICS != "" {
|
||||
if err := validateFileReadable(c.Calendar.LocalICS); err != nil {
|
||||
return fmt.Errorf("calendar ICS file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate diary ICS path if specified
|
||||
if c.Diary.ICSFile != "" {
|
||||
dir := filepath.Dir(c.Diary.ICSFile)
|
||||
if err := validateDirExists(dir); err != nil {
|
||||
return fmt.Errorf("diary ICS directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate CalDAV settings if enabled
|
||||
if c.CalDAV.Enabled {
|
||||
if c.CalDAV.URL == "" {
|
||||
return fmt.Errorf("caldav URL is required when caldav is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate photos folder if specified
|
||||
if c.Photos.RootFolder != "" {
|
||||
if err := validateDirExists(c.Photos.RootFolder); err != nil {
|
||||
return fmt.Errorf("photos root folder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFileReadable(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("file does not exist: %s", path)
|
||||
}
|
||||
return fmt.Errorf("cannot access file: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("path is a directory, not a file: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDirExists(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("directory does not exist: %s", path)
|
||||
}
|
||||
return fmt.Errorf("cannot access directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is not a directory: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
357
internal/handlers/handlers.go
Normal file
357
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,357 @@
|
||||
// Package handlers contains HTTP handlers for all routes.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/luxick/chronological/internal/models"
|
||||
"github.com/luxick/chronological/internal/storage"
|
||||
)
|
||||
|
||||
// Handlers holds all HTTP handlers and their dependencies.
|
||||
type Handlers struct {
|
||||
templates *template.Template
|
||||
icsStore *storage.ICSStore
|
||||
photoStore *storage.PhotoStore
|
||||
caldavStore *storage.CalDAVStore
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
func New(tmpl *template.Template, ics *storage.ICSStore, photos *storage.PhotoStore, caldav *storage.CalDAVStore) *Handlers {
|
||||
return &Handlers{
|
||||
templates: tmpl,
|
||||
icsStore: ics,
|
||||
photoStore: photos,
|
||||
caldavStore: caldav,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleIndex serves the main timeline page.
|
||||
func (h *Handlers) HandleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
data := h.buildPageData(now.Year(), now.Month())
|
||||
h.render(w, "index.html", data)
|
||||
}
|
||||
|
||||
// HandleCalendar serves the calendar grid partial for HTMX.
|
||||
func (h *Handlers) HandleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
year, month, err := parseYearMonth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data := h.buildCalendarData(year, month)
|
||||
h.render(w, "calendar.html", data)
|
||||
}
|
||||
|
||||
// HandleTimeline serves the timeline partial for HTMX.
|
||||
func (h *Handlers) HandleTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
year, month, err := parseYearMonth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data := h.buildTimelineData(year, month)
|
||||
h.render(w, "timeline.html", data)
|
||||
}
|
||||
|
||||
// HandleDay serves the day detail view.
|
||||
func (h *Handlers) HandleDay(w http.ResponseWriter, r *http.Request) {
|
||||
year, month, err := parseYearMonth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dayStr := r.PathValue("day")
|
||||
day, err := strconv.Atoi(dayStr)
|
||||
if err != nil || day < 1 || day > 31 {
|
||||
http.Error(w, "Invalid day", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
data := h.buildDayData(date)
|
||||
h.render(w, "day.html", data)
|
||||
}
|
||||
|
||||
// HandleGetDiary serves a diary entry for a specific date.
|
||||
func (h *Handlers) HandleGetDiary(w http.ResponseWriter, r *http.Request) {
|
||||
date, err := parseDate(r.PathValue("date"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := h.icsStore.LoadDiaryEntry(date)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load diary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Date": date,
|
||||
"Entry": entry,
|
||||
}
|
||||
h.render(w, "diary_form.html", data)
|
||||
}
|
||||
|
||||
// HandleSaveDiary saves or updates a diary entry.
|
||||
func (h *Handlers) HandleSaveDiary(w http.ResponseWriter, r *http.Request) {
|
||||
date, err := parseDate(r.PathValue("date"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
text := r.FormValue("text")
|
||||
entry := &models.DiaryEntry{
|
||||
Date: date,
|
||||
Text: text,
|
||||
}
|
||||
|
||||
if err := h.icsStore.SaveDiaryEntry(entry); err != nil {
|
||||
http.Error(w, "Failed to save diary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated day content
|
||||
data := h.buildDayData(date)
|
||||
h.render(w, "day_content.html", data)
|
||||
}
|
||||
|
||||
// HandleDeleteDiary deletes a diary entry.
|
||||
func (h *Handlers) HandleDeleteDiary(w http.ResponseWriter, r *http.Request) {
|
||||
date, err := parseDate(r.PathValue("date"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.icsStore.DeleteDiaryEntry(date); err != nil {
|
||||
http.Error(w, "Failed to delete diary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated day content
|
||||
data := h.buildDayData(date)
|
||||
h.render(w, "day_content.html", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) buildPageData(year int, month time.Month) map[string]any {
|
||||
return map[string]any{
|
||||
"Year": year,
|
||||
"Month": month,
|
||||
"Calendar": h.buildCalendarData(year, month),
|
||||
"Timeline": h.buildTimelineData(year, month),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) buildCalendarData(year int, month time.Month) map[string]any {
|
||||
// Load content indicators for each day
|
||||
events, _ := h.icsStore.LoadEventsForMonth(year, month)
|
||||
diaries, _ := h.icsStore.LoadDiaryEntries()
|
||||
photos, _ := h.photoStore.LoadPhotosForMonth(year, month)
|
||||
|
||||
// Load CalDAV events if available
|
||||
if h.caldavStore != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
caldavEvents, err := h.caldavStore.LoadEventsForMonth(ctx, year, month)
|
||||
if err == nil {
|
||||
events = append(events, caldavEvents...)
|
||||
}
|
||||
}
|
||||
|
||||
// Build day content map
|
||||
days := make(map[int]*models.DayContent)
|
||||
daysInMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.Local).Day()
|
||||
|
||||
for day := 1; day <= daysInMonth; day++ {
|
||||
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
content := &models.DayContent{Date: date}
|
||||
|
||||
// Add events for this day
|
||||
for _, event := range events {
|
||||
if event.IsOnDate(date) {
|
||||
content.Events = append(content.Events, event)
|
||||
}
|
||||
}
|
||||
|
||||
// Add diary entry for this day
|
||||
for _, diary := range diaries {
|
||||
if diary.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
|
||||
content.Diary = diary
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Add photos for this day
|
||||
for _, photo := range photos {
|
||||
if photo.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
|
||||
content.Photos = append(content.Photos, photo)
|
||||
}
|
||||
}
|
||||
|
||||
days[day] = content
|
||||
}
|
||||
|
||||
// Calculate first day offset (0 = Sunday)
|
||||
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
|
||||
firstDayWeekday := int(firstDay.Weekday())
|
||||
|
||||
// Previous/next month
|
||||
prevMonth := time.Date(year, month-1, 1, 0, 0, 0, 0, time.Local)
|
||||
nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, time.Local)
|
||||
|
||||
return map[string]any{
|
||||
"Year": year,
|
||||
"Month": month,
|
||||
"MonthName": month.String(),
|
||||
"DaysInMonth": daysInMonth,
|
||||
"FirstDayWeekday": firstDayWeekday,
|
||||
"Days": days,
|
||||
"PrevYear": prevMonth.Year(),
|
||||
"PrevMonth": int(prevMonth.Month()),
|
||||
"NextYear": nextMonth.Year(),
|
||||
"NextMonth": int(nextMonth.Month()),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) buildTimelineData(year int, month time.Month) map[string]any {
|
||||
events, _ := h.icsStore.LoadEventsForMonth(year, month)
|
||||
diaries, _ := h.icsStore.LoadDiaryEntries()
|
||||
photos, _ := h.photoStore.LoadPhotosForMonth(year, month)
|
||||
|
||||
// Load CalDAV events if available
|
||||
if h.caldavStore != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
caldavEvents, err := h.caldavStore.LoadEventsForMonth(ctx, year, month)
|
||||
if err == nil {
|
||||
events = append(events, caldavEvents...)
|
||||
}
|
||||
}
|
||||
|
||||
// Build entries grouped by day
|
||||
daysInMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.Local).Day()
|
||||
var daysWithContent []*models.DayContent
|
||||
|
||||
for day := 1; day <= daysInMonth; day++ {
|
||||
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
content := &models.DayContent{Date: date}
|
||||
|
||||
for _, event := range events {
|
||||
if event.IsOnDate(date) {
|
||||
content.Events = append(content.Events, event)
|
||||
}
|
||||
}
|
||||
|
||||
for _, diary := range diaries {
|
||||
if diary.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
|
||||
content.Diary = diary
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, photo := range photos {
|
||||
if photo.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
|
||||
content.Photos = append(content.Photos, photo)
|
||||
}
|
||||
}
|
||||
|
||||
if content.HasContent() {
|
||||
daysWithContent = append(daysWithContent, content)
|
||||
}
|
||||
}
|
||||
|
||||
// Previous/next month
|
||||
prevMonth := time.Date(year, month-1, 1, 0, 0, 0, 0, time.Local)
|
||||
nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, time.Local)
|
||||
|
||||
return map[string]any{
|
||||
"Year": year,
|
||||
"Month": month,
|
||||
"MonthName": month.String(),
|
||||
"Days": daysWithContent,
|
||||
"PrevYear": prevMonth.Year(),
|
||||
"PrevMonth": int(prevMonth.Month()),
|
||||
"NextYear": nextMonth.Year(),
|
||||
"NextMonth": int(nextMonth.Month()),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) buildDayData(date time.Time) map[string]any {
|
||||
content := &models.DayContent{Date: date}
|
||||
|
||||
events, _ := h.icsStore.LoadEventsForMonth(date.Year(), date.Month())
|
||||
for _, event := range events {
|
||||
if event.IsOnDate(date) {
|
||||
content.Events = append(content.Events, event)
|
||||
}
|
||||
}
|
||||
|
||||
// Load CalDAV events if available
|
||||
if h.caldavStore != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
caldavEvents, err := h.caldavStore.LoadEventsForMonth(ctx, date.Year(), date.Month())
|
||||
if err == nil {
|
||||
for _, event := range caldavEvents {
|
||||
if event.IsOnDate(date) {
|
||||
content.Events = append(content.Events, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry, _ := h.icsStore.LoadDiaryEntry(date)
|
||||
content.Diary = entry
|
||||
|
||||
photos, _ := h.photoStore.LoadPhotosForDate(date)
|
||||
content.Photos = photos
|
||||
|
||||
return map[string]any{
|
||||
"Date": date,
|
||||
"Content": content,
|
||||
}
|
||||
}
|
||||
|
||||
func parseYearMonth(r *http.Request) (int, time.Month, error) {
|
||||
yearStr := r.PathValue("year")
|
||||
monthStr := r.PathValue("month")
|
||||
|
||||
year, err := strconv.Atoi(yearStr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
monthInt, err := strconv.Atoi(monthStr)
|
||||
if err != nil || monthInt < 1 || monthInt > 12 {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return year, time.Month(monthInt), nil
|
||||
}
|
||||
|
||||
func parseDate(dateStr string) (time.Time, error) {
|
||||
return time.Parse("2006-01-02", dateStr)
|
||||
}
|
||||
14
internal/models/diary.go
Normal file
14
internal/models/diary.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DiaryEntry represents a journal entry for a specific day.
|
||||
type DiaryEntry struct {
|
||||
Date time.Time
|
||||
Text string
|
||||
}
|
||||
|
||||
// DateString returns the date formatted as YYYY-MM-DD.
|
||||
func (d *DiaryEntry) DateString() string {
|
||||
return d.Date.Format("2006-01-02")
|
||||
}
|
||||
37
internal/models/event.go
Normal file
37
internal/models/event.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Package models defines the core data structures for the application.
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Event represents a calendar event.
|
||||
type Event struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
AllDay bool
|
||||
Location string
|
||||
Source EventSource
|
||||
}
|
||||
|
||||
// EventSource indicates where an event originated from.
|
||||
type EventSource string
|
||||
|
||||
const (
|
||||
SourceLocal EventSource = "local"
|
||||
SourceCalDAV EventSource = "caldav"
|
||||
)
|
||||
|
||||
// IsOnDate checks if the event occurs on the given date.
|
||||
func (e *Event) IsOnDate(date time.Time) bool {
|
||||
eventDate := e.Start.Truncate(24 * time.Hour)
|
||||
checkDate := date.Truncate(24 * time.Hour)
|
||||
|
||||
if e.AllDay {
|
||||
endDate := e.End.Truncate(24 * time.Hour)
|
||||
return !checkDate.Before(eventDate) && checkDate.Before(endDate)
|
||||
}
|
||||
|
||||
return eventDate.Equal(checkDate)
|
||||
}
|
||||
30
internal/models/photo.go
Normal file
30
internal/models/photo.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Photo represents a photo with its metadata.
|
||||
type Photo struct {
|
||||
Date time.Time
|
||||
Year string
|
||||
Filename string
|
||||
Description string
|
||||
FullPath string
|
||||
}
|
||||
|
||||
// DateString returns the date formatted as YYYY-MM-DD.
|
||||
func (p *Photo) DateString() string {
|
||||
return p.Date.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// URLPath returns the URL path to access this photo.
|
||||
func (p *Photo) URLPath() string {
|
||||
return filepath.Join("/photos", p.Year, p.Filename)
|
||||
}
|
||||
|
||||
// ThumbURLPath returns the URL path to access this photo's thumbnail.
|
||||
func (p *Photo) ThumbURLPath() string {
|
||||
return filepath.Join("/photos/thumb", p.Year, p.Filename)
|
||||
}
|
||||
104
internal/models/timeline.go
Normal file
104
internal/models/timeline.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimelineEntry represents any item that can appear in the timeline.
|
||||
type TimelineEntry struct {
|
||||
Date time.Time
|
||||
Type EntryType
|
||||
Event *Event
|
||||
Diary *DiaryEntry
|
||||
Photo *Photo
|
||||
}
|
||||
|
||||
// EntryType indicates what kind of entry this is.
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
EntryTypeEvent EntryType = "event"
|
||||
EntryTypeDiary EntryType = "diary"
|
||||
EntryTypePhoto EntryType = "photo"
|
||||
)
|
||||
|
||||
// DayContent aggregates all content for a specific day.
|
||||
type DayContent struct {
|
||||
Date time.Time
|
||||
Events []*Event
|
||||
Diary *DiaryEntry
|
||||
Photos []*Photo
|
||||
}
|
||||
|
||||
// HasContent returns true if there is any content for this day.
|
||||
func (d *DayContent) HasContent() bool {
|
||||
return len(d.Events) > 0 || d.Diary != nil || len(d.Photos) > 0
|
||||
}
|
||||
|
||||
// HasEvents returns true if there are events on this day.
|
||||
func (d *DayContent) HasEvents() bool {
|
||||
return len(d.Events) > 0
|
||||
}
|
||||
|
||||
// HasDiary returns true if there is a diary entry for this day.
|
||||
func (d *DayContent) HasDiary() bool {
|
||||
return d.Diary != nil
|
||||
}
|
||||
|
||||
// HasPhotos returns true if there are photos for this day.
|
||||
func (d *DayContent) HasPhotos() bool {
|
||||
return len(d.Photos) > 0
|
||||
}
|
||||
|
||||
// ToTimelineEntries converts day content to a sorted slice of timeline entries.
|
||||
func (d *DayContent) ToTimelineEntries() []TimelineEntry {
|
||||
var entries []TimelineEntry
|
||||
|
||||
for _, event := range d.Events {
|
||||
entries = append(entries, TimelineEntry{
|
||||
Date: event.Start,
|
||||
Type: EntryTypeEvent,
|
||||
Event: event,
|
||||
})
|
||||
}
|
||||
|
||||
if d.Diary != nil {
|
||||
entries = append(entries, TimelineEntry{
|
||||
Date: d.Diary.Date,
|
||||
Type: EntryTypeDiary,
|
||||
Diary: d.Diary,
|
||||
})
|
||||
}
|
||||
|
||||
for _, photo := range d.Photos {
|
||||
entries = append(entries, TimelineEntry{
|
||||
Date: photo.Date,
|
||||
Type: EntryTypePhoto,
|
||||
Photo: photo,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Date.Before(entries[j].Date)
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// MonthData holds all data for a specific month.
|
||||
type MonthData struct {
|
||||
Year int
|
||||
Month time.Month
|
||||
Days map[int]*DayContent
|
||||
}
|
||||
|
||||
// GetDay returns the day content for a specific day number.
|
||||
func (m *MonthData) GetDay(day int) *DayContent {
|
||||
if content, ok := m.Days[day]; ok {
|
||||
return content
|
||||
}
|
||||
return &DayContent{
|
||||
Date: time.Date(m.Year, m.Month, day, 0, 0, 0, 0, time.Local),
|
||||
}
|
||||
}
|
||||
217
internal/server/server.go
Normal file
217
internal/server/server.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Package server sets up and runs the HTTP server.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/luxick/chronological/internal/config"
|
||||
"github.com/luxick/chronological/internal/handlers"
|
||||
"github.com/luxick/chronological/internal/storage"
|
||||
)
|
||||
|
||||
// TemplatesFS and StaticFS are set from main package where embed works
|
||||
var TemplatesFS embed.FS
|
||||
var StaticFS embed.FS
|
||||
|
||||
// Server represents the HTTP server and its dependencies.
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
httpServer *http.Server
|
||||
templates *template.Template
|
||||
icsStore *storage.ICSStore
|
||||
photoStore *storage.PhotoStore
|
||||
caldavStore *storage.CalDAVStore
|
||||
}
|
||||
|
||||
// New creates a new server with the given configuration.
|
||||
func New(cfg *config.Config) (*Server, error) {
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
icsStore: storage.NewICSStore(cfg.Calendar.LocalICS, cfg.Diary.ICSFile),
|
||||
photoStore: storage.NewPhotoStore(cfg.Photos.RootFolder),
|
||||
}
|
||||
|
||||
// Initialize CalDAV store if enabled
|
||||
if cfg.CalDAV.Enabled {
|
||||
s.caldavStore = storage.NewCalDAVStore(cfg.CalDAV.URL, cfg.CalDAV.Username, cfg.CalDAV.Password)
|
||||
}
|
||||
|
||||
// Parse templates
|
||||
if err := s.parseTemplates(); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse templates: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) parseTemplates() error {
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": formatDate,
|
||||
"formatTime": formatTime,
|
||||
"formatDateTime": formatDateTime,
|
||||
"formatMonth": formatMonth,
|
||||
"daysInMonth": daysInMonth,
|
||||
"weekday": weekday,
|
||||
"add": add,
|
||||
"sub": sub,
|
||||
"seq": seq,
|
||||
"isToday": isToday,
|
||||
"isSameMonth": isSameMonth,
|
||||
}
|
||||
|
||||
// Get the templates subdirectory
|
||||
templatesDir, err := fs.Sub(TemplatesFS, "templates")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get templates subdirectory: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesDir,
|
||||
"layouts/*.html",
|
||||
"partials/*.html",
|
||||
"pages/*.html",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse templates: %w", err)
|
||||
}
|
||||
|
||||
s.templates = tmpl
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and blocks until shutdown.
|
||||
func (s *Server) Run() error {
|
||||
// Connect to CalDAV if enabled
|
||||
if s.caldavStore != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.caldavStore.Connect(ctx); err != nil {
|
||||
log.Printf("Warning: failed to connect to CalDAV server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create handlers
|
||||
h := handlers.New(s.templates, s.icsStore, s.photoStore, s.caldavStore)
|
||||
|
||||
// Set up routes
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static files
|
||||
staticDir, err := fs.Sub(StaticFS, "static")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get static subdirectory: %w", err)
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticDir))))
|
||||
|
||||
// Photo files (served from configured directory)
|
||||
if s.config.Photos.RootFolder != "" {
|
||||
mux.Handle("GET /photos/", http.StripPrefix("/photos/", http.FileServer(http.Dir(s.config.Photos.RootFolder))))
|
||||
}
|
||||
|
||||
// Page routes
|
||||
mux.HandleFunc("GET /", h.HandleIndex)
|
||||
mux.HandleFunc("GET /calendar/{year}/{month}", h.HandleCalendar)
|
||||
mux.HandleFunc("GET /timeline/{year}/{month}", h.HandleTimeline)
|
||||
mux.HandleFunc("GET /day/{year}/{month}/{day}", h.HandleDay)
|
||||
|
||||
// Diary routes
|
||||
mux.HandleFunc("GET /diary/{date}", h.HandleGetDiary)
|
||||
mux.HandleFunc("POST /diary/{date}", h.HandleSaveDiary)
|
||||
mux.HandleFunc("DELETE /diary/{date}", h.HandleDeleteDiary)
|
||||
|
||||
// Create HTTP server
|
||||
s.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.config.Server.Port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
log.Printf("Starting server on http://localhost:%d", s.config.Server.Port)
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("server shutdown error: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Template helper functions
|
||||
|
||||
func formatDate(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
|
||||
func formatDateTime(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func formatMonth(t time.Time) string {
|
||||
return t.Format("January 2006")
|
||||
}
|
||||
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.Local).Day()
|
||||
}
|
||||
|
||||
func weekday(year int, month time.Month, day int) int {
|
||||
return int(time.Date(year, month, day, 0, 0, 0, 0, time.Local).Weekday())
|
||||
}
|
||||
|
||||
func add(a, b int) int {
|
||||
return a + b
|
||||
}
|
||||
|
||||
func sub(a, b int) int {
|
||||
return a - b
|
||||
}
|
||||
|
||||
func seq(start, end int) []int {
|
||||
s := make([]int, end-start+1)
|
||||
for i := range s {
|
||||
s[i] = start + i
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isToday(year int, month time.Month, day int) bool {
|
||||
now := time.Now()
|
||||
return now.Year() == year && now.Month() == month && now.Day() == day
|
||||
}
|
||||
|
||||
func isSameMonth(year int, month time.Month) bool {
|
||||
now := time.Now()
|
||||
return now.Year() == year && now.Month() == month
|
||||
}
|
||||
185
internal/storage/caldav.go
Normal file
185
internal/storage/caldav.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"github.com/luxick/chronological/internal/models"
|
||||
)
|
||||
|
||||
// CalDAVStore handles fetching events from a CalDAV server.
|
||||
type CalDAVStore struct {
|
||||
url string
|
||||
username string
|
||||
password string
|
||||
client *caldav.Client
|
||||
}
|
||||
|
||||
// NewCalDAVStore creates a new CalDAV store with the given credentials.
|
||||
func NewCalDAVStore(url, username, password string) *CalDAVStore {
|
||||
return &CalDAVStore{
|
||||
url: url,
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes a connection to the CalDAV server.
|
||||
func (s *CalDAVStore) Connect(ctx context.Context) error {
|
||||
httpClient := &http.Client{
|
||||
Transport: &basicAuthTransport{
|
||||
username: s.username,
|
||||
password: s.password,
|
||||
},
|
||||
}
|
||||
|
||||
client, err := caldav.NewClient(httpClient, s.url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create caldav client: %w", err)
|
||||
}
|
||||
|
||||
s.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadEvents fetches events from the CalDAV server for a date range.
|
||||
func (s *CalDAVStore) LoadEvents(ctx context.Context, start, end time.Time) ([]*models.Event, error) {
|
||||
if s.client == nil {
|
||||
return nil, fmt.Errorf("caldav client not connected")
|
||||
}
|
||||
|
||||
principal, err := s.client.FindCurrentUserPrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user principal: %w", err)
|
||||
}
|
||||
|
||||
homeSet, err := s.client.FindCalendarHomeSet(ctx, principal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find calendar home set: %w", err)
|
||||
}
|
||||
|
||||
calendars, err := s.client.FindCalendars(ctx, homeSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find calendars: %w", err)
|
||||
}
|
||||
|
||||
var allEvents []*models.Event
|
||||
|
||||
for _, cal := range calendars {
|
||||
query := &caldav.CalendarQuery{
|
||||
CompRequest: caldav.CalendarCompRequest{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CalendarCompRequest{{
|
||||
Name: "VEVENT",
|
||||
Props: []string{
|
||||
"SUMMARY",
|
||||
"DESCRIPTION",
|
||||
"DTSTART",
|
||||
"DTEND",
|
||||
"LOCATION",
|
||||
"UID",
|
||||
},
|
||||
}},
|
||||
},
|
||||
CompFilter: caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
Start: start,
|
||||
End: end,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
objects, err := s.client.QueryCalendar(ctx, cal.Path, query)
|
||||
if err != nil {
|
||||
continue // Skip calendars that fail
|
||||
}
|
||||
|
||||
events, err := parseCalDAVObjects(objects)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
allEvents = append(allEvents, events...)
|
||||
}
|
||||
|
||||
return allEvents, nil
|
||||
}
|
||||
|
||||
// LoadEventsForMonth fetches events for a specific month.
|
||||
func (s *CalDAVStore) LoadEventsForMonth(ctx context.Context, year int, month time.Month) ([]*models.Event, error) {
|
||||
start := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
return s.LoadEvents(ctx, start, end)
|
||||
}
|
||||
|
||||
func parseCalDAVObjects(objects []caldav.CalendarObject) ([]*models.Event, error) {
|
||||
var events []*models.Event
|
||||
|
||||
for _, obj := range objects {
|
||||
cal := obj.Data
|
||||
if cal == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, child := range cal.Children {
|
||||
if child.Name != ical.CompEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
event := ical.Event{Component: child}
|
||||
|
||||
uid, _ := event.Props.Text(ical.PropUID)
|
||||
summary, _ := event.Props.Text(ical.PropSummary)
|
||||
description, _ := event.Props.Text(ical.PropDescription)
|
||||
location, _ := event.Props.Text(ical.PropLocation)
|
||||
|
||||
dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dtend, err := event.Props.DateTime(ical.PropDateTimeEnd, time.Local)
|
||||
if err != nil {
|
||||
dtend = dtstart.Add(time.Hour)
|
||||
}
|
||||
|
||||
// Check if all-day event
|
||||
prop := event.Props.Get(ical.PropDateTimeStart)
|
||||
allDay := false
|
||||
if prop != nil {
|
||||
valueParam := prop.Params.Get(ical.ParamValue)
|
||||
allDay = valueParam == "DATE"
|
||||
}
|
||||
|
||||
events = append(events, &models.Event{
|
||||
ID: uid,
|
||||
Title: summary,
|
||||
Description: description,
|
||||
Start: dtstart,
|
||||
End: dtend,
|
||||
AllDay: allDay,
|
||||
Location: location,
|
||||
Source: models.SourceCalDAV,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// basicAuthTransport adds basic auth to HTTP requests.
|
||||
type basicAuthTransport struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.SetBasicAuth(t.username, t.password)
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
341
internal/storage/ics.go
Normal file
341
internal/storage/ics.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// Package storage handles reading and writing data from various sources.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/luxick/chronological/internal/models"
|
||||
)
|
||||
|
||||
// ICSStore handles reading and writing ICS calendar files.
|
||||
type ICSStore struct {
|
||||
calendarPath string
|
||||
diaryPath string
|
||||
}
|
||||
|
||||
// NewICSStore creates a new ICS store with the given file paths.
|
||||
func NewICSStore(calendarPath, diaryPath string) *ICSStore {
|
||||
return &ICSStore{
|
||||
calendarPath: calendarPath,
|
||||
diaryPath: diaryPath,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadEvents loads all events from the calendar ICS file.
|
||||
func (s *ICSStore) LoadEvents() ([]*models.Event, error) {
|
||||
if s.calendarPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(s.calendarPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open calendar file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dec := ical.NewDecoder(file)
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode calendar: %w", err)
|
||||
}
|
||||
|
||||
return parseEventsFromCalendar(cal, models.SourceLocal)
|
||||
}
|
||||
|
||||
// LoadEventsForMonth loads events that occur within the specified month.
|
||||
func (s *ICSStore) LoadEventsForMonth(year int, month time.Month) ([]*models.Event, error) {
|
||||
allEvents, err := s.LoadEvents()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
start := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
|
||||
var monthEvents []*models.Event
|
||||
for _, event := range allEvents {
|
||||
if eventInRange(event, start, end) {
|
||||
monthEvents = append(monthEvents, event)
|
||||
}
|
||||
}
|
||||
|
||||
return monthEvents, nil
|
||||
}
|
||||
|
||||
// LoadDiaryEntries loads all diary entries from the diary ICS file.
|
||||
func (s *ICSStore) LoadDiaryEntries() ([]*models.DiaryEntry, error) {
|
||||
if s.diaryPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(s.diaryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open diary file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dec := ical.NewDecoder(file)
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode diary calendar: %w", err)
|
||||
}
|
||||
|
||||
return parseDiaryFromCalendar(cal)
|
||||
}
|
||||
|
||||
// LoadDiaryEntry loads a single diary entry for a specific date.
|
||||
func (s *ICSStore) LoadDiaryEntry(date time.Time) (*models.DiaryEntry, error) {
|
||||
entries, err := s.LoadDiaryEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetDate := date.Truncate(24 * time.Hour)
|
||||
for _, entry := range entries {
|
||||
if entry.Date.Truncate(24 * time.Hour).Equal(targetDate) {
|
||||
return entry, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SaveDiaryEntry saves or updates a diary entry for a specific date.
|
||||
func (s *ICSStore) SaveDiaryEntry(entry *models.DiaryEntry) error {
|
||||
if s.diaryPath == "" {
|
||||
return fmt.Errorf("diary path not configured")
|
||||
}
|
||||
|
||||
var cal *ical.Calendar
|
||||
|
||||
// Try to load existing calendar
|
||||
file, err := os.Open(s.diaryPath)
|
||||
if err == nil {
|
||||
dec := ical.NewDecoder(file)
|
||||
cal, err = dec.Decode()
|
||||
file.Close()
|
||||
if err != nil {
|
||||
cal = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create new calendar if none exists
|
||||
if cal == nil {
|
||||
cal = ical.NewCalendar()
|
||||
cal.Props.SetText(ical.PropVersion, "2.0")
|
||||
cal.Props.SetText(ical.PropProductID, "-//Chronological//Diary//EN")
|
||||
}
|
||||
|
||||
targetDate := entry.Date.Truncate(24 * time.Hour)
|
||||
found := false
|
||||
|
||||
// Update existing entry if found
|
||||
for _, child := range cal.Children {
|
||||
if child.Name != ical.CompEvent {
|
||||
continue
|
||||
}
|
||||
event := ical.Event{Component: child}
|
||||
summary, _ := event.Props.Text(ical.PropSummary)
|
||||
if summary != "Chronolog" {
|
||||
continue
|
||||
}
|
||||
|
||||
dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if dtstart.Truncate(24 * time.Hour).Equal(targetDate) {
|
||||
event.Props.SetText(ical.PropDescription, entry.Text)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create new entry if not found
|
||||
if !found {
|
||||
event := ical.NewEvent()
|
||||
event.Props.SetText(ical.PropUID, fmt.Sprintf("chronolog-%s@chronological", entry.DateString()))
|
||||
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
|
||||
event.Props.SetDate(ical.PropDateTimeStart, targetDate)
|
||||
event.Props.SetDate(ical.PropDateTimeEnd, targetDate.AddDate(0, 0, 1))
|
||||
event.Props.SetText(ical.PropSummary, "Chronolog")
|
||||
event.Props.SetText(ical.PropDescription, entry.Text)
|
||||
cal.Children = append(cal.Children, event.Component)
|
||||
}
|
||||
|
||||
// Write calendar back to file
|
||||
outFile, err := os.Create(s.diaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create diary file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
enc := ical.NewEncoder(outFile)
|
||||
if err := enc.Encode(cal); err != nil {
|
||||
return fmt.Errorf("failed to encode diary calendar: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDiaryEntry removes a diary entry for a specific date.
|
||||
func (s *ICSStore) DeleteDiaryEntry(date time.Time) error {
|
||||
if s.diaryPath == "" {
|
||||
return fmt.Errorf("diary path not configured")
|
||||
}
|
||||
|
||||
file, err := os.Open(s.diaryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to open diary file: %w", err)
|
||||
}
|
||||
|
||||
dec := ical.NewDecoder(file)
|
||||
cal, err := dec.Decode()
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode diary calendar: %w", err)
|
||||
}
|
||||
|
||||
targetDate := date.Truncate(24 * time.Hour)
|
||||
var newChildren []*ical.Component
|
||||
|
||||
for _, child := range cal.Children {
|
||||
keep := true
|
||||
|
||||
if child.Name == ical.CompEvent {
|
||||
event := ical.Event{Component: child}
|
||||
summary, _ := event.Props.Text(ical.PropSummary)
|
||||
if summary == "Chronolog" {
|
||||
dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local)
|
||||
if err == nil && dtstart.Truncate(24*time.Hour).Equal(targetDate) {
|
||||
keep = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keep {
|
||||
newChildren = append(newChildren, child)
|
||||
}
|
||||
}
|
||||
|
||||
cal.Children = newChildren
|
||||
|
||||
outFile, err := os.Create(s.diaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create diary file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
enc := ical.NewEncoder(outFile)
|
||||
if err := enc.Encode(cal); err != nil {
|
||||
return fmt.Errorf("failed to encode diary calendar: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEventsFromCalendar(cal *ical.Calendar, source models.EventSource) ([]*models.Event, error) {
|
||||
var events []*models.Event
|
||||
|
||||
for _, child := range cal.Children {
|
||||
if child.Name != ical.CompEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
event := ical.Event{Component: child}
|
||||
|
||||
uid, _ := event.Props.Text(ical.PropUID)
|
||||
summary, _ := event.Props.Text(ical.PropSummary)
|
||||
description, _ := event.Props.Text(ical.PropDescription)
|
||||
location, _ := event.Props.Text(ical.PropLocation)
|
||||
|
||||
dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local)
|
||||
if err != nil {
|
||||
// Try date-only format for all-day events
|
||||
dtstart, err = event.Props.DateTime(ical.PropDateTimeStart, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
dtend, err := event.Props.DateTime(ical.PropDateTimeEnd, time.Local)
|
||||
if err != nil {
|
||||
dtend = dtstart.Add(time.Hour)
|
||||
}
|
||||
|
||||
// Check if all-day event (date-only, no time component)
|
||||
allDay := isAllDayEvent(&event)
|
||||
|
||||
events = append(events, &models.Event{
|
||||
ID: uid,
|
||||
Title: summary,
|
||||
Description: description,
|
||||
Start: dtstart,
|
||||
End: dtend,
|
||||
AllDay: allDay,
|
||||
Location: location,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func parseDiaryFromCalendar(cal *ical.Calendar) ([]*models.DiaryEntry, error) {
|
||||
var entries []*models.DiaryEntry
|
||||
|
||||
for _, child := range cal.Children {
|
||||
if child.Name != ical.CompEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
event := ical.Event{Component: child}
|
||||
|
||||
summary, _ := event.Props.Text(ical.PropSummary)
|
||||
if summary != "Chronolog" {
|
||||
continue
|
||||
}
|
||||
|
||||
dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
description, _ := event.Props.Text(ical.PropDescription)
|
||||
|
||||
entries = append(entries, &models.DiaryEntry{
|
||||
Date: dtstart.Truncate(24 * time.Hour),
|
||||
Text: description,
|
||||
})
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func isAllDayEvent(event *ical.Event) bool {
|
||||
prop := event.Props.Get(ical.PropDateTimeStart)
|
||||
if prop == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// All-day events have VALUE=DATE parameter
|
||||
valueParam := prop.Params.Get(ical.ParamValue)
|
||||
return valueParam == "DATE"
|
||||
}
|
||||
|
||||
func eventInRange(event *models.Event, start, end time.Time) bool {
|
||||
return event.Start.Before(end) && event.End.After(start)
|
||||
}
|
||||
167
internal/storage/photos.go
Normal file
167
internal/storage/photos.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxick/chronological/internal/models"
|
||||
)
|
||||
|
||||
// PhotoStore handles scanning and serving photos from the filesystem.
|
||||
type PhotoStore struct {
|
||||
rootFolder string
|
||||
}
|
||||
|
||||
// NewPhotoStore creates a new photo store with the given root folder.
|
||||
func NewPhotoStore(rootFolder string) *PhotoStore {
|
||||
return &PhotoStore{
|
||||
rootFolder: rootFolder,
|
||||
}
|
||||
}
|
||||
|
||||
// Supported image extensions
|
||||
var supportedExtensions = map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
}
|
||||
|
||||
// Filename pattern: YYYY-MM-DD_description.ext
|
||||
var filenamePattern = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})_(.+)\.(\w+)$`)
|
||||
|
||||
// LoadAllPhotos scans and loads all photos from the configured directory.
|
||||
func (s *PhotoStore) LoadAllPhotos() ([]*models.Photo, error) {
|
||||
if s.rootFolder == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var photos []*models.Photo
|
||||
|
||||
// Walk through year folders
|
||||
entries, err := os.ReadDir(s.rootFolder)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read photo root folder: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
yearFolder := entry.Name()
|
||||
yearPath := filepath.Join(s.rootFolder, yearFolder)
|
||||
|
||||
yearPhotos, err := s.loadPhotosFromYear(yearFolder, yearPath)
|
||||
if err != nil {
|
||||
continue // Skip problematic folders
|
||||
}
|
||||
|
||||
photos = append(photos, yearPhotos...)
|
||||
}
|
||||
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
// LoadPhotosForMonth loads photos for a specific month.
|
||||
func (s *PhotoStore) LoadPhotosForMonth(year int, month time.Month) ([]*models.Photo, error) {
|
||||
allPhotos, err := s.LoadAllPhotos()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var monthPhotos []*models.Photo
|
||||
for _, photo := range allPhotos {
|
||||
if photo.Date.Year() == year && photo.Date.Month() == month {
|
||||
monthPhotos = append(monthPhotos, photo)
|
||||
}
|
||||
}
|
||||
|
||||
return monthPhotos, nil
|
||||
}
|
||||
|
||||
// LoadPhotosForDate loads photos for a specific date.
|
||||
func (s *PhotoStore) LoadPhotosForDate(date time.Time) ([]*models.Photo, error) {
|
||||
allPhotos, err := s.LoadAllPhotos()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetDate := date.Truncate(24 * time.Hour)
|
||||
var datePhotos []*models.Photo
|
||||
|
||||
for _, photo := range allPhotos {
|
||||
if photo.Date.Truncate(24 * time.Hour).Equal(targetDate) {
|
||||
datePhotos = append(datePhotos, photo)
|
||||
}
|
||||
}
|
||||
|
||||
return datePhotos, nil
|
||||
}
|
||||
|
||||
// GetPhotoPath returns the full filesystem path for a photo.
|
||||
func (s *PhotoStore) GetPhotoPath(year, filename string) string {
|
||||
return filepath.Join(s.rootFolder, year, filename)
|
||||
}
|
||||
|
||||
func (s *PhotoStore) loadPhotosFromYear(yearFolder, yearPath string) ([]*models.Photo, error) {
|
||||
var photos []*models.Photo
|
||||
|
||||
entries, err := os.ReadDir(yearPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := entry.Name()
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
if !supportedExtensions[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
photo := parsePhotoFilename(yearFolder, filename, filepath.Join(yearPath, filename))
|
||||
if photo != nil {
|
||||
photos = append(photos, photo)
|
||||
}
|
||||
}
|
||||
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
func parsePhotoFilename(yearFolder, filename, fullPath string) *models.Photo {
|
||||
matches := filenamePattern.FindStringSubmatch(filename)
|
||||
if matches == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dateStr := fmt.Sprintf("%s-%s-%s", matches[1], matches[2], matches[3])
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
description := matches[4]
|
||||
// Convert underscores and camelCase to spaces for display
|
||||
description = strings.ReplaceAll(description, "_", " ")
|
||||
|
||||
return &models.Photo{
|
||||
Date: date,
|
||||
Year: yearFolder,
|
||||
Filename: filename,
|
||||
Description: description,
|
||||
FullPath: fullPath,
|
||||
}
|
||||
}
|
||||
10
web/embed.go
Normal file
10
web/embed.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Package web provides embedded static assets and templates.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:templates
|
||||
var TemplatesFS embed.FS
|
||||
|
||||
//go:embed all:static
|
||||
var StaticFS embed.FS
|
||||
517
web/static/css/style.css
Normal file
517
web/static/css/style.css
Normal file
@@ -0,0 +1,517 @@
|
||||
/* Chronological - Main Stylesheet */
|
||||
|
||||
/* CSS Reset and Base Styles */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-dark: #2563eb;
|
||||
--color-secondary: #64748b;
|
||||
--color-success: #22c55e;
|
||||
--color-danger: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
--color-bg: #f8fafc;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e2e8f0;
|
||||
--color-text: #1e293b;
|
||||
--color-text-muted: #64748b;
|
||||
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.25rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* App Layout */
|
||||
.app-header {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Timeline Container - Split Layout */
|
||||
.timeline-container {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Calendar Panel */
|
||||
.calendar-panel {
|
||||
background-color: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.calendar-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text);
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background-color: var(--color-bg);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
.calendar-day.empty {
|
||||
background: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-day.today:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.calendar-day.has-content {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.day-indicators {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
font-size: 0.6rem;
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
margin-top: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Timeline Panel */
|
||||
.timeline-panel {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.timeline-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.timeline-day {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-day-header {
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.timeline-day-header time {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.timeline-entries {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.timeline-entry {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-content h4 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.entry-location {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.entry-description {
|
||||
margin: var(--spacing-sm) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.diary-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Photo Grid */
|
||||
.photo-grid, .photo-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.photo-item img:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.photo-item figcaption {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--spacing-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Day Modal */
|
||||
.day-modal {
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.day-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.day-detail {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.day-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.day-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.day-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.day-section h3 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.event-item strong {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-md);
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.calendar-panel {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* HTMX Loading Indicator */
|
||||
.htmx-request {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.htmx-request::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: -10px 0 0 -10px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
74
web/static/js/app.js
Normal file
74
web/static/js/app.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// Chronological - Minimal JavaScript
|
||||
// Most interactivity is handled by HTMX
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Close modal when clicking backdrop
|
||||
document.querySelectorAll('dialog').forEach(function(dialog) {
|
||||
dialog.addEventListener('click', function(event) {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Event delegation for dynamic content
|
||||
document.body.addEventListener('click', function(event) {
|
||||
var target = event.target;
|
||||
|
||||
// Handle modal opening via data attribute
|
||||
var modalOpener = target.closest('[data-opens-modal]');
|
||||
if (modalOpener) {
|
||||
var modalId = modalOpener.getAttribute('data-opens-modal');
|
||||
var modal = document.getElementById(modalId);
|
||||
if (modal && modal.showModal) {
|
||||
modal.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle diary edit toggle
|
||||
if (target.matches('[data-action="toggle-diary-edit"]')) {
|
||||
var display = target.closest('.diary-display');
|
||||
if (display) {
|
||||
display.style.display = 'none';
|
||||
var editForm = display.nextElementSibling;
|
||||
if (editForm) {
|
||||
editForm.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle diary edit cancel
|
||||
if (target.matches('[data-action="cancel-diary-edit"]')) {
|
||||
var editDiv = target.closest('.diary-edit');
|
||||
if (editDiv) {
|
||||
editDiv.style.display = 'none';
|
||||
var displayDiv = editDiv.previousElementSibling;
|
||||
if (displayDiv) {
|
||||
displayDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
document.addEventListener('keydown', function(event) {
|
||||
// Close modal on Escape
|
||||
if (event.key === 'Escape') {
|
||||
document.querySelectorAll('dialog[open]').forEach(function(dialog) {
|
||||
dialog.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// HTMX event handlers
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
// Re-initialize any dynamic elements after HTMX swaps
|
||||
if (event.detail.target.id === 'day-modal-content') {
|
||||
// Focus textarea when opening day modal with diary form
|
||||
var textarea = event.detail.target.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
1
web/static/js/htmx.min.js
vendored
Normal file
1
web/static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
web/templates/layouts/base.html
Normal file
21
web/templates/layouts/base.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chronological</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<h1>Chronological</h1>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
14
web/templates/pages/index.html
Normal file
14
web/templates/pages/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{{define "index.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="timeline-container">
|
||||
<aside class="calendar-panel" id="calendar-panel">
|
||||
{{template "calendar.html" .Calendar}}
|
||||
</aside>
|
||||
<section class="timeline-panel" id="timeline-panel">
|
||||
{{template "timeline.html" .Timeline}}
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
80
web/templates/partials/calendar.html
Normal file
80
web/templates/partials/calendar.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{{define "calendar.html"}}
|
||||
<div class="calendar">
|
||||
<div class="calendar-header">
|
||||
<button
|
||||
hx-get="/calendar/{{.PrevYear}}/{{.PrevMonth}}"
|
||||
hx-target="#calendar-panel"
|
||||
hx-swap="innerHTML"
|
||||
class="nav-btn"
|
||||
aria-label="Previous month">
|
||||
‹
|
||||
</button>
|
||||
<h2>{{.MonthName}} {{.Year}}</h2>
|
||||
<button
|
||||
hx-get="/calendar/{{.NextYear}}/{{.NextMonth}}"
|
||||
hx-target="#calendar-panel"
|
||||
hx-swap="innerHTML"
|
||||
class="nav-btn"
|
||||
aria-label="Next month">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-weekdays">
|
||||
<span>Sun</span>
|
||||
<span>Mon</span>
|
||||
<span>Tue</span>
|
||||
<span>Wed</span>
|
||||
<span>Thu</span>
|
||||
<span>Fri</span>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid">
|
||||
{{/* Empty cells before first day */}}
|
||||
{{range seq 0 (sub .FirstDayWeekday 1)}}
|
||||
<div class="calendar-day empty"></div>
|
||||
{{end}}
|
||||
|
||||
{{/* Day cells */}}
|
||||
{{range $day := seq 1 .DaysInMonth}}
|
||||
{{$content := index $.Days $day}}
|
||||
<div
|
||||
class="calendar-day{{if isToday $.Year $.Month $day}} today{{end}}{{if $content.HasContent}} has-content{{end}}"
|
||||
hx-get="/day/{{$.Year}}/{{$.Month | printf "%d"}}/{{$day}}"
|
||||
hx-target="#day-modal-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="click"
|
||||
data-opens-modal="day-modal">
|
||||
<span class="day-number">{{$day}}</span>
|
||||
{{if $content.HasContent}}
|
||||
<div class="day-indicators">
|
||||
{{if $content.HasEvents}}<span class="indicator event-indicator" title="Events">📅</span>{{end}}
|
||||
{{if $content.HasDiary}}<span class="indicator diary-indicator" title="Diary">📝</span>{{end}}
|
||||
{{if $content.HasPhotos}}<span class="indicator photo-indicator" title="Photos">📷</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="calendar-actions">
|
||||
<button
|
||||
hx-get="/timeline/{{.Year}}/{{.Month | printf "%d"}}"
|
||||
hx-target="#timeline-panel"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-primary">
|
||||
View Timeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="day-modal" class="day-modal">
|
||||
<div id="day-modal-content">
|
||||
<!-- Day content loaded via HTMX -->
|
||||
</div>
|
||||
<form method="dialog">
|
||||
<button class="btn btn-secondary">Close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{{end}}
|
||||
112
web/templates/partials/day.html
Normal file
112
web/templates/partials/day.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{{define "day.html"}}
|
||||
<div class="day-detail">
|
||||
<header class="day-header">
|
||||
<h2>{{.Date.Format "Monday, January 2, 2006"}}</h2>
|
||||
</header>
|
||||
|
||||
<div id="day-detail-content">
|
||||
{{template "day_content.html" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "day_content.html"}}
|
||||
<div class="day-content">
|
||||
{{/* Events Section */}}
|
||||
<section class="day-section events-section">
|
||||
<h3>📅 Events</h3>
|
||||
{{if .Content.Events}}
|
||||
<ul class="event-list">
|
||||
{{range .Content.Events}}
|
||||
<li class="event-item">
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if not .AllDay}}
|
||||
<span class="event-time">{{formatTime .Start}} - {{formatTime .End}}</span>
|
||||
{{else}}
|
||||
<span class="event-time">All day</span>
|
||||
{{end}}
|
||||
{{if .Location}}
|
||||
<span class="event-location">📍 {{.Location}}</span>
|
||||
{{end}}
|
||||
{{if .Description}}
|
||||
<p class="event-description">{{.Description}}</p>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-message">No events scheduled.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* Diary Section */}}
|
||||
<section class="day-section diary-section">
|
||||
<h3>📝 Journal</h3>
|
||||
{{if .Content.Diary}}
|
||||
<div class="diary-display">
|
||||
<p class="diary-text">{{.Content.Diary.Text}}</p>
|
||||
<button
|
||||
class="btn btn-small"
|
||||
data-action="toggle-diary-edit">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div class="diary-edit" style="display: none;">
|
||||
<form
|
||||
hx-post="/diary/{{formatDate .Date}}"
|
||||
hx-target="#day-detail-content"
|
||||
hx-swap="innerHTML">
|
||||
<textarea name="text" rows="5" placeholder="Write your thoughts...">{{.Content.Diary.Text}}</textarea>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-action="cancel-diary-edit">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
hx-delete="/diary/{{formatDate .Date}}"
|
||||
hx-target="#day-detail-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Delete this diary entry?">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<form
|
||||
hx-post="/diary/{{formatDate .Date}}"
|
||||
hx-target="#day-detail-content"
|
||||
hx-swap="innerHTML">
|
||||
<textarea name="text" rows="5" placeholder="Write your thoughts..."></textarea>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* Photos Section */}}
|
||||
<section class="day-section photos-section">
|
||||
<h3>📷 Photos</h3>
|
||||
{{if .Content.Photos}}
|
||||
<div class="photo-gallery">
|
||||
{{range .Content.Photos}}
|
||||
<figure class="photo-item">
|
||||
<a href="{{.URLPath}}" target="_blank">
|
||||
<img src="{{.URLPath}}" alt="{{.Description}}" loading="lazy">
|
||||
</a>
|
||||
<figcaption>{{.Description}}</figcaption>
|
||||
</figure>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No photos for this day.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
26
web/templates/partials/diary_form.html
Normal file
26
web/templates/partials/diary_form.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "diary_form.html"}}
|
||||
<div class="diary-form">
|
||||
<h3>Journal Entry - {{.Date.Format "January 2, 2006"}}</h3>
|
||||
<form
|
||||
hx-post="/diary/{{formatDate .Date}}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
<textarea
|
||||
name="text"
|
||||
rows="8"
|
||||
placeholder="Write your thoughts...">{{if .Entry}}{{.Entry.Text}}{{end}}</textarea>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
{{if .Entry}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
hx-delete="/diary/{{formatDate .Date}}"
|
||||
hx-confirm="Delete this diary entry?">
|
||||
Delete
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
93
web/templates/partials/timeline.html
Normal file
93
web/templates/partials/timeline.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{{define "timeline.html"}}
|
||||
<div class="timeline">
|
||||
<div class="timeline-header">
|
||||
<button
|
||||
hx-get="/timeline/{{.PrevYear}}/{{.PrevMonth}}"
|
||||
hx-target="#timeline-panel"
|
||||
hx-swap="innerHTML"
|
||||
class="nav-btn"
|
||||
aria-label="Previous month">
|
||||
‹
|
||||
</button>
|
||||
<h2>{{.MonthName}} {{.Year}}</h2>
|
||||
<button
|
||||
hx-get="/timeline/{{.NextYear}}/{{.NextMonth}}"
|
||||
hx-target="#timeline-panel"
|
||||
hx-swap="innerHTML"
|
||||
class="nav-btn"
|
||||
aria-label="Next month">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="timeline-content">
|
||||
{{if .Days}}
|
||||
{{range .Days}}
|
||||
<article class="timeline-day">
|
||||
<header class="timeline-day-header">
|
||||
<time datetime="{{formatDate .Date}}">
|
||||
{{.Date.Format "Monday, January 2"}}
|
||||
</time>
|
||||
</header>
|
||||
|
||||
<div class="timeline-entries">
|
||||
{{/* Events */}}
|
||||
{{range .Events}}
|
||||
<div class="timeline-entry event-entry">
|
||||
<div class="entry-icon">📅</div>
|
||||
<div class="entry-content">
|
||||
<h4>{{.Title}}</h4>
|
||||
{{if not .AllDay}}
|
||||
<span class="entry-time">{{formatTime .Start}} - {{formatTime .End}}</span>
|
||||
{{else}}
|
||||
<span class="entry-time">All day</span>
|
||||
{{end}}
|
||||
{{if .Location}}
|
||||
<span class="entry-location">📍 {{.Location}}</span>
|
||||
{{end}}
|
||||
{{if .Description}}
|
||||
<p class="entry-description">{{.Description}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Diary entry */}}
|
||||
{{if .Diary}}
|
||||
<div class="timeline-entry diary-entry">
|
||||
<div class="entry-icon">📝</div>
|
||||
<div class="entry-content">
|
||||
<h4>Journal Entry</h4>
|
||||
<p class="entry-description diary-text">{{.Diary.Text}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Photos */}}
|
||||
{{if .Photos}}
|
||||
<div class="timeline-entry photo-entry">
|
||||
<div class="entry-icon">📷</div>
|
||||
<div class="entry-content">
|
||||
<h4>Photos</h4>
|
||||
<div class="photo-grid">
|
||||
{{range .Photos}}
|
||||
<figure class="photo-item">
|
||||
<img src="{{.URLPath}}" alt="{{.Description}}" loading="lazy">
|
||||
<figcaption>{{.Description}}</figcaption>
|
||||
</figure>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="timeline-empty">
|
||||
<p>No entries for this month.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user